mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-30 23:59:02 +03:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96846f7bf6 | ||
|
|
50f17e6852 | ||
|
|
e9ef4f978a | ||
|
|
2858662be9 | ||
|
|
88f944fe81 | ||
|
|
39a3667f39 | ||
|
|
2ed70bd693 | ||
|
|
90248ef243 | ||
|
|
7e0e0635b8 | ||
|
|
ec44398b1c | ||
|
|
d556ff8df2 | ||
|
|
3a09d01bbe | ||
|
|
0bdf5002a0 | ||
|
|
a4a592b4e6 |
@@ -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">
|
||||
|
||||
@@ -149,9 +149,16 @@ For more detail, see [Frigate Tip: Best Practices for Training Face and Custom C
|
||||
- **The wizard is just the starting point**: You don't need to find and label every class upfront. Missing classes will naturally appear in Recent Classifications, and those images tend to be more valuable because they represent new conditions and edge cases.
|
||||
- **Problem framing**: Keep classes visually distinct and relevant to the chosen object types.
|
||||
- **Preprocessing**: Ensure examples reflect object crops similar to Frigate's boxes; keep the subject centered.
|
||||
- **Labels**: Keep label names short and consistent; include a `none` class if you plan to ignore uncertain predictions for sub labels.
|
||||
- **Crop size**: Aim for crops of at least 100×100 pixels (a 10,000 pixel area). Crops smaller than ~80×80 get stretched 3-7× by the model's 224×224 input resize and tend to collapse into a generic "blob" region of feature space where identity becomes unreliable. If most of your detections are small because the camera is far from the subject, consider repositioning the camera for closer crops.
|
||||
- **Class balance**: Aim to keep your largest class within ~3× the count of your smallest. Beyond that, the model becomes biased toward the dominant class and tends to default borderline predictions to it (the "everything looks like Buddy" failure mode).
|
||||
- **Threshold**: Tune `threshold` per model to reduce false assignments. Start at `0.8` and adjust based on validation.
|
||||
|
||||
:::tip `none` works differently from named classes
|
||||
|
||||
Named classes work best with visually uniform examples — every Buddy photo should look like Buddy. The `none` class needs the opposite: visual diversity across sizes, framings, and qualities, because at inference it has to absorb everything that isn't one of your named classes. Don't apply the same "only keep large, well-framed images" rule to `none` that you would to a named class. Mix in small crops, partial views, and false positives deliberately - otherwise the model has no signal for "small/ambiguous thing = not one of my known classes" and will force those crops into a named class by default.
|
||||
|
||||
:::
|
||||
|
||||
## Debugging Classification Models
|
||||
|
||||
To troubleshoot issues with object classification models, enable debug logging to see detailed information about classification attempts, scores, and consensus calculations.
|
||||
|
||||
@@ -49,15 +49,14 @@ You should have at least 8 GB of RAM available (or VRAM if running on GPU) to ru
|
||||
|
||||
### Model Types: Instruct vs Thinking
|
||||
|
||||
Most vision-language models are available as **instruct** models, which are fine-tuned to follow instructions and respond concisely to prompts. However, some models (such as certain Qwen-VL or minigpt variants) offer both **instruct** and **thinking** versions.
|
||||
Vision-language models come in **instruct** variants (fine-tuned to follow instructions and respond concisely), **thinking** variants (fine-tuned for free-form, speculative reasoning), and **hybrid** variants that support both modes per request. Most modern vision-language models are hybrid.
|
||||
|
||||
- **Instruct models** are always recommended for use with Frigate. These models generate direct, relevant, actionable descriptions that best fit Frigate's object and event summary use case.
|
||||
- **Reasoning / Thinking models** are fine-tuned for more free-form, open-ended, and speculative outputs, which are typically not concise and may not provide the practical summaries Frigate expects. For this reason, Frigate does **not** recommend or support using thinking models.
|
||||
Frigate manages reasoning per task automatically:
|
||||
|
||||
Some models are labeled as **hybrid** (capable of both thinking and instruct tasks). In these cases, it is recommended to disable reasoning / thinking, which is generally model specific (see your models documentation).
|
||||
- **Description tasks** (object descriptions, review descriptions, review summaries) are synthesis-only and benefit from concise, direct output, so Frigate disables thinking for these calls when the model exposes a per-request toggle.
|
||||
- **Chat** lets you toggle thinking on or off from the composer when the configured model supports it.
|
||||
|
||||
**Recommendation:**
|
||||
Always select the `-instruct` or documented instruct/tagged variant of any model you use in your Frigate configuration. If in doubt, refer to your model provider's documentation or model library for guidance on the correct model variant to use.
|
||||
You can use a pure instruct, hybrid, or thinking-capable model with Frigate — no extra configuration is required to disable thinking for descriptions.
|
||||
|
||||
### llama.cpp
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -88,8 +88,18 @@ Configure a "friendly name" for your stream followed by the go2rtc stream name.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Live playback" />, then select your camera.
|
||||
- Under **Live stream names**, add entries mapping a friendly name to each go2rtc stream name (e.g., `Main Stream` mapped to `test_cam`, `Sub Stream` mapped to `test_cam_sub`).
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Live playback" /> and select your camera.
|
||||
2. Under **Live stream names**, click **Add stream** to add a new entry.
|
||||
3. In the **Stream name** field, enter a friendly name that will appear in the Live UI's stream dropdown (e.g., `Main Stream`).
|
||||
4. In the **go2rtc stream** field, open the dropdown and select the go2rtc stream this name should map to (e.g., `test_cam`). The dropdown lists every stream configured under `go2rtc.streams`. If the go2rtc stream hasn't been created yet, you can type the name and choose **Use "..."** to save a custom value.
|
||||
5. Repeat for each additional stream you want to expose (e.g., `Sub Stream` → `test_cam_sub`).
|
||||
6. Use the trash icon on a row to remove a stream, then **Save** the section.
|
||||
|
||||
:::tip
|
||||
|
||||
Configure your go2rtc streams first under <NavPath path="Settings > System > go2rtc streams" /> so the dropdown is populated with valid options.
|
||||
|
||||
:::
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -257,19 +267,47 @@ 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. The Off state persists across Frigate restarts via a `.runtime_state.json` file alongside `config.yml` (see [Runtime toggle persistence](#runtime-toggle-persistence)).
|
||||
- **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.
|
||||
|
||||
#### Runtime toggle persistence
|
||||
|
||||
The Live view toggles for **camera on/off**, **detect**, **recordings**, **snapshots**, and **audio detection** — along with the equivalent MQTT `/set` topics — write the new state to `.runtime_state.json` next to your `config.yml`. The file is replayed on Frigate startup so your last-known toggle states survive a restart. Two interactions worth knowing:
|
||||
|
||||
- **Settings UI saves win.** When you save a field through **Settings → Global configuration**, the matching entry is cleared from `.runtime_state.json` so the new value in your config file is the durable source.
|
||||
- **Switching profiles clears all runtime overrides.** Activating or deactivating a [profile](/configuration/profiles) is treated as a deliberate state change, so the file is wiped to avoid stale overrides replaying on top of the new profile.
|
||||
|
||||
If you hand-edit `config.yml` while runtime overrides exist, the overrides will still replay on restart. Delete `.runtime_state.json` to reset to the YAML-defined defaults.
|
||||
|
||||
### Live player error messages
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,11 @@ 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.
|
||||
|
||||
Activating or deactivating a profile clears any [runtime toggle overrides](/configuration/live#runtime-toggle-persistence) so the profile's settings aren't silently undone by a stale toggle from before the switch.
|
||||
|
||||
## Example: Home / Away Setup
|
||||
|
||||
@@ -135,10 +139,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 +211,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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -23,7 +23,7 @@ In 0.14 and later, all of that is bundled into a single review item which starts
|
||||
|
||||
## Alerts and Detections
|
||||
|
||||
Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate 0.14 categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them.
|
||||
Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -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 kebab 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 kebab 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,15 +368,15 @@ 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 persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)). To permanently change the configured value, 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`
|
||||
|
||||
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
|
||||
### `frigate/<camera_name>/detect/state`
|
||||
|
||||
@@ -384,7 +384,7 @@ Topic with current state of object detection for a camera. Published values are
|
||||
|
||||
### `frigate/<camera_name>/audio/set`
|
||||
|
||||
Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
|
||||
### `frigate/<camera_name>/audio/state`
|
||||
|
||||
@@ -392,7 +392,7 @@ Topic with current state of audio detection for a camera. Published values are `
|
||||
|
||||
### `frigate/<camera_name>/recordings/set`
|
||||
|
||||
Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
|
||||
### `frigate/<camera_name>/recordings/state`
|
||||
|
||||
@@ -400,7 +400,7 @@ Topic with current state of recordings for a camera. Published values are `ON` a
|
||||
|
||||
### `frigate/<camera_name>/snapshots/set`
|
||||
|
||||
Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
|
||||
|
||||
### `frigate/<camera_name>/snapshots/state`
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ id: dummy-camera
|
||||
title: Analyzing Object Detection
|
||||
---
|
||||
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Frigate provides several tools for investigating object detection and tracking behavior: reviewing recorded detections through the UI, using the built-in Debug Replay feature, and manually setting up a dummy camera for advanced scenarios.
|
||||
|
||||
## Reviewing Detections in the UI
|
||||
@@ -51,11 +53,25 @@ Only one replay session can be active at a time. If a session is already running
|
||||
|
||||
:::
|
||||
|
||||
### Starting Debug Replay
|
||||
|
||||
Debug Replay can be started from several places in the UI. The starting point determines the time range that gets replayed.
|
||||
|
||||
- **History — Actions menu.** Navigate to <NavPath path="History > {camera}" />, open the **Actions** menu in the toolbar, and choose **Debug Replay**. From here you can pick a preset (**Last 1 Minute**, **Last 5 Minutes**), select a range directly on the timeline with **From Timeline**, or enter exact start and end times with **Custom**. This is the most flexible option and the best choice when you want to add padding around a detection. On mobile, the same options appear in the Actions drawer.
|
||||
- **History — Detail Stream event menu.** While viewing a review item in the Detail Stream, open the menu on a tracked object's event card and choose **Debug Replay**. The replay range is set automatically to that object's start and end times.
|
||||
- **Explore — search result menu.** From an Explore card, open the kebab menu and choose **Debug Replay**. The range is taken from the tracked object's lifecycle.
|
||||
- **Explore — Tracking Details Actions menu.** Open a tracked object's **Tracking Details** dialog, then choose **Debug Replay** from the Actions menu. Same automatic range as the search result menu.
|
||||
- **Exports — export card menu.** From <NavPath path="Exports" />, open the menu on an export and choose **Debug Replay** to loop the exported clip through the detection pipeline for the camera it was exported from.
|
||||
|
||||
The Detail Stream, Explore, and Exports entry points use the underlying recording or export's bounds with a small amount of padding. This can be convenient for quick checks, but if a detection is short or you want extra "settle" time for motion and the detector, start the replay from the History Actions menu instead and widen the range manually.
|
||||
|
||||
### Variables to consider
|
||||
|
||||
- The replay will not always produce identical results to the original run. Different frames may be selected on replay, which can change detections and tracking.
|
||||
- Motion detection depends on the exact frames used; small frame shifts can change motion regions and therefore what gets passed to the detector.
|
||||
- Object detection is not fully deterministic: models and post-processing can yield slightly different results across runs.
|
||||
- In cases where a detection is short and a replay may only be a small number of frames, it is recommended to manually add some padding before and after the detection so that the motion and object detectors have time to settle into the scene. Rather than starting Debug Replay from Explore, navigate to History for your camera, choose Debug Replay from the Actions menu, and click the "From Timeline" or "Custom" option.
|
||||
- The replay camera inherits the source camera's zones. Any automations that trigger on those zone names will fire for the replay camera as well. This can be helpful when debugging zone behavior, but may be unexpected. You can add a condition on the source camera's name in your automation if you want to exclude replay triggers.
|
||||
|
||||
Treat the replay as a close approximation rather than an exact reproduction. Run multiple loops and examine the debug overlays and logs to understand the behavior.
|
||||
|
||||
|
||||
@@ -750,6 +750,40 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
settings,
|
||||
)
|
||||
|
||||
# detect resize also republishes motion + objects so other
|
||||
# processes pick up the rebuilt masks, and fires refresh so
|
||||
# the camera maintainer recycles the camera process to pick
|
||||
# up the new ffmpeg cmd / SHM sizing
|
||||
if field == "detect":
|
||||
cam_cfg = config.cameras.get(camera)
|
||||
if cam_cfg is not None:
|
||||
if cam_cfg.motion is not None:
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.motion, camera
|
||||
),
|
||||
cam_cfg.motion,
|
||||
)
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.objects, camera
|
||||
),
|
||||
cam_cfg.objects,
|
||||
)
|
||||
if cam_cfg.zones:
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.zones, camera
|
||||
),
|
||||
cam_cfg.zones,
|
||||
)
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum.refresh, camera
|
||||
),
|
||||
cam_cfg,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Config applied in-memory"},
|
||||
status_code=200,
|
||||
@@ -874,6 +908,11 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# drop runtime overrides for any fields the user just rewrote in
|
||||
# yaml so a stale override doesn't silently win after restart
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
|
||||
|
||||
if body.requires_restart == 0 or body.update_topic:
|
||||
old_config: FrigateConfig = request.app.frigate_config
|
||||
request.app.frigate_config = config
|
||||
|
||||
+20
-1
@@ -547,9 +547,21 @@ async def _execute_get_live_context(
|
||||
camera: str,
|
||||
allowed_cameras: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
# Reject wildcards explicitly so models retry with a real camera name
|
||||
# instead of silently fanning out across every camera.
|
||||
if camera in ("*", "all"):
|
||||
return {
|
||||
"error": (
|
||||
"get_live_context requires a single camera name; wildcards "
|
||||
"are not supported. Call this tool once per camera."
|
||||
),
|
||||
"available_cameras": allowed_cameras,
|
||||
}
|
||||
|
||||
if camera not in allowed_cameras:
|
||||
return {
|
||||
"error": f"Camera '{camera}' not found or access denied",
|
||||
"available_cameras": allowed_cameras,
|
||||
}
|
||||
|
||||
if camera not in request.app.frigate_config.cameras:
|
||||
@@ -721,7 +733,14 @@ async def _execute_tool_internal(
|
||||
"Arguments: %s",
|
||||
json.dumps(arguments),
|
||||
)
|
||||
return {"error": "Camera parameter is required"}
|
||||
return {
|
||||
"error": (
|
||||
"get_live_context requires a single camera name; "
|
||||
"wildcards and empty values are not supported. "
|
||||
"Call this tool once per camera."
|
||||
),
|
||||
"available_cameras": allowed_cameras,
|
||||
}
|
||||
return await _execute_get_live_context(request, camera, allowed_cameras)
|
||||
elif tool_name == "start_camera_watch":
|
||||
return await _execute_start_camera_watch(request, arguments)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
@@ -36,7 +37,7 @@ from frigate.comms.event_metadata_updater import (
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.config.profile_manager import ProfileManager
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
from frigate.genai import GenAIClientManager
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
@@ -116,6 +117,11 @@ def create_fastapi_app(
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
logger.info("FastAPI started")
|
||||
asyncio.create_task(
|
||||
debug_replay_auto_stop_watchdog(
|
||||
replay_manager, frigate_config, config_publisher
|
||||
)
|
||||
)
|
||||
|
||||
# Rate limiter (used for login endpoint)
|
||||
if frigate_config.auth.failed_login_rate_limit is None:
|
||||
|
||||
+8
-1
@@ -348,7 +348,11 @@ class FrigateApp:
|
||||
persisted in cam.profiles for cam in self.config.cameras.values()
|
||||
):
|
||||
logger.info("Restoring persisted profile '%s'", persisted)
|
||||
self.profile_manager.activate_profile(persisted)
|
||||
# don't clear runtime overrides here, restore_runtime_state() later
|
||||
# in startup replays it on top of the activated profile
|
||||
self.profile_manager.activate_profile(
|
||||
persisted, clear_runtime_overrides=False
|
||||
)
|
||||
|
||||
def start_detectors(self) -> None:
|
||||
for name in self.config.cameras.keys():
|
||||
@@ -612,6 +616,9 @@ class FrigateApp:
|
||||
self.start_record_cleanup()
|
||||
self.start_watchdog()
|
||||
|
||||
# restore persisted runtime overrides on top of config
|
||||
self.dispatcher.restore_runtime_state()
|
||||
|
||||
self.init_auth()
|
||||
|
||||
try:
|
||||
|
||||
@@ -14,6 +14,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Regions
|
||||
from frigate.util.builtin import empty_and_close_queue
|
||||
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
|
||||
@@ -50,6 +51,7 @@ class CameraMaintainer(threading.Thread):
|
||||
[
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.refresh,
|
||||
],
|
||||
)
|
||||
self.shm_count = self.__calculate_shm_frame_count()
|
||||
@@ -202,6 +204,25 @@ class CameraMaintainer(threading.Thread):
|
||||
capture_process.terminate()
|
||||
capture_process.join()
|
||||
|
||||
def __unlink_camera_frame_slots(self, camera: str) -> None:
|
||||
"""Drop the camera's per-frame YUV SHM segments from this
|
||||
process's frame_manager and unlink them at the OS level.
|
||||
|
||||
Safe to call after the camera's capture/processor subprocesses
|
||||
have been joined — they no longer hold mappings, so unlink frees
|
||||
the segments immediately. Other long-lived processes that opened
|
||||
these slots will continue using their existing mappings until
|
||||
they call frame_manager.get with a shape that no longer fits
|
||||
(the get path drops and reopens stale refs).
|
||||
"""
|
||||
prefix = f"{camera}_frame"
|
||||
names = [n for n in list(self.frame_manager.shm_store) if n.startswith(prefix)]
|
||||
for name in names:
|
||||
try:
|
||||
self.frame_manager.delete(name)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not unlink SHM %s: %s", name, exc)
|
||||
|
||||
def __stop_camera_process(self, camera: str) -> None:
|
||||
camera_process = self.camera_processes.get(camera)
|
||||
if camera_process is not None:
|
||||
@@ -253,12 +274,45 @@ class CameraMaintainer(threading.Thread):
|
||||
for camera in updated_cameras:
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
self.__unlink_camera_frame_slots(camera)
|
||||
self.capture_processes.pop(camera, None)
|
||||
self.camera_processes.pop(camera, None)
|
||||
self.camera_stop_events.pop(camera, None)
|
||||
self.region_grids.pop(camera, None)
|
||||
self.camera_metrics.pop(camera, None)
|
||||
self.ptz_metrics.pop(camera, None)
|
||||
elif update_type == CameraConfigUpdateEnum.refresh.name:
|
||||
# Recycle replay cameras so detect width/height/fps
|
||||
# propagate through ffmpeg args, SHM sizing, and the
|
||||
# region grid. Regular cameras detect change still
|
||||
# requires a full restart.
|
||||
for camera in updated_cameras:
|
||||
if not camera.startswith(REPLAY_CAMERA_PREFIX):
|
||||
continue
|
||||
|
||||
new_config = self.update_subscriber.camera_configs.get(camera)
|
||||
if new_config is None:
|
||||
# remove arrived in the same batch
|
||||
continue
|
||||
|
||||
if (
|
||||
camera not in self.camera_processes
|
||||
and camera not in self.capture_processes
|
||||
):
|
||||
continue
|
||||
|
||||
# rebuild ffmpeg cmds on the shared config so the
|
||||
# new subprocesses spawn with current args
|
||||
new_config.recreate_ffmpeg_cmds()
|
||||
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
self.__unlink_camera_frame_slots(camera)
|
||||
self.capture_processes.pop(camera, None)
|
||||
self.camera_processes.pop(camera, None)
|
||||
|
||||
self.__start_camera_processor(camera, new_config, runtime=True)
|
||||
self.__start_camera_capture(camera, new_config, runtime=True)
|
||||
|
||||
# ensure the capture processes are done
|
||||
for camera in self.capture_processes.keys():
|
||||
|
||||
+52
-8
@@ -45,6 +45,7 @@ class CameraState:
|
||||
self.frame_cache: dict[float, dict[str, Any]] = {}
|
||||
self.zone_objects: defaultdict[str, list[Any]] = defaultdict(list)
|
||||
self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8)
|
||||
self._last_frame_shape: tuple[int, int] = self.camera_config.frame_shape_yuv
|
||||
self.current_frame_lock = threading.Lock()
|
||||
self.current_frame_time = 0.0
|
||||
self.motion_boxes: list[tuple[int, int, int, int]] = []
|
||||
@@ -303,6 +304,42 @@ class CameraState:
|
||||
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
|
||||
self.callbacks[event_type].append(callback)
|
||||
|
||||
def _discard_stale_resolution_state(
|
||||
self, current_detections: dict[str, dict[str, Any]]
|
||||
) -> bool:
|
||||
"""Drop tracked state when the camera's detect resolution has
|
||||
changed, and signal the caller to skip this batch if it contains
|
||||
out-of-bounds boxes from the pre-recycle detect process.
|
||||
|
||||
Returns True when the batch should be skipped entirely.
|
||||
"""
|
||||
# detect resolution changed — drop tracked state so old-grid
|
||||
# boxes don't leak through end-callbacks
|
||||
current_shape = self.camera_config.frame_shape_yuv
|
||||
if current_shape != self._last_frame_shape:
|
||||
logger.debug(
|
||||
f"{self.name}: detect resolution changed {self._last_frame_shape} -> {current_shape}, dropping tracked state"
|
||||
)
|
||||
with self.current_frame_lock:
|
||||
self.tracked_objects.clear()
|
||||
self.motion_boxes = []
|
||||
self.regions = []
|
||||
self._last_frame_shape = current_shape
|
||||
|
||||
# drop in-flight batches from the pre-recycle detect process
|
||||
# whose boxes exceed the current detect resolution
|
||||
detect = self.camera_config.detect
|
||||
if detect.width is not None and detect.height is not None:
|
||||
for obj in current_detections.values():
|
||||
box = obj.get("box")
|
||||
if box and (box[2] > detect.width or box[3] > detect.height):
|
||||
logger.debug(
|
||||
f"{self.name}: dropping stale-resolution detection batch (box {box} exceeds {detect.width}x{detect.height})"
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def update(
|
||||
self,
|
||||
frame_name: str,
|
||||
@@ -311,6 +348,9 @@ class CameraState:
|
||||
motion_boxes: list[tuple[int, int, int, int]],
|
||||
regions: list[tuple[int, int, int, int]],
|
||||
) -> None:
|
||||
if self._discard_stale_resolution_state(current_detections):
|
||||
return
|
||||
|
||||
current_frame = self.frame_manager.get(
|
||||
frame_name, self.camera_config.frame_shape_yuv
|
||||
)
|
||||
@@ -332,14 +372,18 @@ class CameraState:
|
||||
current_detections[id],
|
||||
)
|
||||
|
||||
# add initial frame to frame cache
|
||||
logger.debug(
|
||||
f"{self.name}: New object, adding {frame_time} to frame cache for {id}"
|
||||
)
|
||||
self.frame_cache[frame_time] = {
|
||||
"frame": np.copy(current_frame), # type: ignore[arg-type]
|
||||
"object_id": id,
|
||||
}
|
||||
# Skip caching when the frame buffer isn't readable — e.g.
|
||||
# frame_manager.get returned None because the SHM segment was
|
||||
# unlinked or hasn't been recreated yet during a camera
|
||||
# add/remove cycle.
|
||||
if current_frame is not None:
|
||||
logger.debug(
|
||||
f"{self.name}: New object, adding {frame_time} to frame cache for {id}"
|
||||
)
|
||||
self.frame_cache[frame_time] = {
|
||||
"frame": np.copy(current_frame),
|
||||
"object_id": id,
|
||||
}
|
||||
|
||||
# save initial thumbnail data and best object
|
||||
thumbnail_data = {
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, Callable, Optional, cast
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
from frigate.comms.webpush import WebPushClient
|
||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
@@ -67,6 +69,7 @@ class Dispatcher:
|
||||
self.embeddings_reindex: dict[str, Any] = {}
|
||||
self.birdseye_layout: dict[str, Any] = {}
|
||||
self.audio_transcription_state: str = "idle"
|
||||
self._runtime_state = RuntimeStatePersistence()
|
||||
self._camera_settings_handlers: dict[str, Callable] = {
|
||||
"audio": self._on_audio_command,
|
||||
"audio_transcription": self._on_audio_transcription_command,
|
||||
@@ -397,6 +400,60 @@ class Dispatcher:
|
||||
for comm in self.comms:
|
||||
comm.stop()
|
||||
|
||||
def restore_runtime_state(self) -> None:
|
||||
"""Replay persisted runtime overrides through the camera settings handlers.
|
||||
|
||||
Called once after Frigate startup completes so processing threads can
|
||||
receive the resulting ``config_updater`` broadcasts. Unknown cameras
|
||||
and topics are skipped; handler exceptions are logged and replay
|
||||
continues for remaining entries.
|
||||
"""
|
||||
state = self._runtime_state.load()
|
||||
for camera_name, features in state.items():
|
||||
if camera_name not in self.config.cameras:
|
||||
continue
|
||||
for topic, value in features.items():
|
||||
handler = self._camera_settings_handlers.get(topic)
|
||||
if handler is None:
|
||||
continue
|
||||
payload = "ON" if value else "OFF"
|
||||
try:
|
||||
handler(camera_name, payload)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to restore runtime state %s.%s=%s",
|
||||
camera_name,
|
||||
topic,
|
||||
payload,
|
||||
)
|
||||
continue
|
||||
logger.info(
|
||||
"Restored runtime state: %s.%s=%s",
|
||||
camera_name,
|
||||
topic,
|
||||
payload,
|
||||
)
|
||||
|
||||
def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
|
||||
"""Clear stored runtime overrides for YAML keys that were just rewritten.
|
||||
|
||||
Called by ``/api/config/set`` after a successful YAML save so an
|
||||
explicit settings-UI save isn't silently overridden by an older
|
||||
runtime toggle on the next restart.
|
||||
"""
|
||||
self._runtime_state.clear_for_yaml_keys(dotted_keys)
|
||||
|
||||
def clear_runtime_state(self) -> None:
|
||||
"""Wipe every stored runtime override.
|
||||
|
||||
Called when a profile is activated or deactivated. A profile switch
|
||||
changes the layer below the runtime overrides, so the stored
|
||||
"steady state" is no longer valid and must be reset; otherwise a
|
||||
subsequent restart would replay stale overrides on top of the new
|
||||
profile-derived in-memory state.
|
||||
"""
|
||||
self._runtime_state.clear_all()
|
||||
|
||||
def _on_detect_command(self, camera_name: str, payload: str) -> None:
|
||||
"""Callback for detect topic."""
|
||||
detect_settings = self.config.cameras[camera_name].detect
|
||||
@@ -428,6 +485,7 @@ class Dispatcher:
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.detect, camera_name),
|
||||
detect_settings,
|
||||
)
|
||||
self._runtime_state.set(camera_name, "detect", detect_settings.enabled)
|
||||
self.publish(f"{camera_name}/detect/state", payload, retain=True)
|
||||
|
||||
def _on_enabled_command(self, camera_name: str, payload: str) -> None:
|
||||
@@ -452,6 +510,7 @@ class Dispatcher:
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, camera_name),
|
||||
camera_settings.enabled,
|
||||
)
|
||||
self._runtime_state.set(camera_name, "enabled", camera_settings.enabled)
|
||||
self.publish(f"{camera_name}/enabled/state", payload, retain=True)
|
||||
|
||||
def _on_motion_command(self, camera_name: str, payload: str) -> None:
|
||||
@@ -614,6 +673,7 @@ class Dispatcher:
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.audio, camera_name),
|
||||
audio_settings,
|
||||
)
|
||||
self._runtime_state.set(camera_name, "audio", audio_settings.enabled)
|
||||
self.publish(f"{camera_name}/audio/state", payload, retain=True)
|
||||
|
||||
def _on_audio_transcription_command(self, camera_name: str, payload: str) -> None:
|
||||
@@ -670,6 +730,7 @@ class Dispatcher:
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.record, camera_name),
|
||||
record_settings,
|
||||
)
|
||||
self._runtime_state.set(camera_name, "recordings", record_settings.enabled)
|
||||
self.publish(f"{camera_name}/recordings/state", payload, retain=True)
|
||||
|
||||
def _on_snapshots_command(self, camera_name: str, payload: str) -> None:
|
||||
@@ -689,6 +750,7 @@ class Dispatcher:
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.snapshots, camera_name),
|
||||
snapshots_settings,
|
||||
)
|
||||
self._runtime_state.set(camera_name, "snapshots", snapshots_settings.enabled)
|
||||
self.publish(f"{camera_name}/snapshots/state", payload, retain=True)
|
||||
|
||||
def _on_ptz_command(self, camera_name: str, payload: str | bytes) -> None:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Persistence layer for dispatcher runtime state overrides."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RuntimeStatePersistence:
|
||||
"""Persist last-known runtime states for dispatcher toggles.
|
||||
|
||||
Stores boolean overrides applied to camera-level toggles by the dispatcher.
|
||||
Overrides are replayed at startup on top of the YAML-derived in-memory
|
||||
config, so changes made via MQTT or the live-view UI survive a restart.
|
||||
"""
|
||||
|
||||
# Maps dispatcher topic name -> YAML key suffix under cameras.<cam>
|
||||
TRACKED_TOPICS: dict[str, str] = {
|
||||
"enabled": "enabled",
|
||||
"detect": "detect.enabled",
|
||||
"snapshots": "snapshots.enabled",
|
||||
"recordings": "record.enabled",
|
||||
"audio": "audio.enabled",
|
||||
}
|
||||
|
||||
_SUFFIX_TO_TOPIC: dict[str, str] = {v: k for k, v in TRACKED_TOPICS.items()}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._path = os.path.join(
|
||||
os.path.dirname(find_config_file()), ".runtime_state.json"
|
||||
)
|
||||
self._lock_path = f"{self._path}.lock"
|
||||
self._lock_timeout = 5
|
||||
|
||||
def load(self) -> dict[str, dict[str, bool]]:
|
||||
"""Return {camera: {topic: bool}} or {} if missing/corrupt."""
|
||||
try:
|
||||
with FileLock(self._lock_path, timeout=self._lock_timeout):
|
||||
data = self._read_locked()
|
||||
except Timeout:
|
||||
logger.error("Timed out acquiring runtime state lock for load")
|
||||
return {}
|
||||
cameras = data.get("cameras", {})
|
||||
if not isinstance(cameras, dict):
|
||||
return {}
|
||||
# Filter out malformed camera entries so callers can trust the shape.
|
||||
return {
|
||||
name: features
|
||||
for name, features in cameras.items()
|
||||
if isinstance(features, dict)
|
||||
}
|
||||
|
||||
def set(self, camera: str, topic: str, value: bool) -> None:
|
||||
"""Persist a single (camera, topic, value). No-op if topic untracked."""
|
||||
if topic not in self.TRACKED_TOPICS:
|
||||
return
|
||||
try:
|
||||
with FileLock(self._lock_path, timeout=self._lock_timeout):
|
||||
data = self._read_locked()
|
||||
cameras = data.setdefault("cameras", {})
|
||||
if not isinstance(cameras, dict):
|
||||
cameras = {}
|
||||
data["cameras"] = cameras
|
||||
cam = cameras.setdefault(camera, {})
|
||||
if not isinstance(cam, dict):
|
||||
cam = {}
|
||||
cameras[camera] = cam
|
||||
cam[topic] = bool(value)
|
||||
self._write_locked(data)
|
||||
except Timeout:
|
||||
logger.error("Timed out persisting runtime state for %s/%s", camera, topic)
|
||||
except OSError:
|
||||
logger.exception("Failed to persist runtime state for %s/%s", camera, topic)
|
||||
|
||||
def clear_all(self) -> None:
|
||||
"""Wipe every stored runtime override.
|
||||
|
||||
Called when the "layer below" changes in a way that invalidates all
|
||||
runtime overrides for the current session (currently: profile
|
||||
activation or deactivation).
|
||||
"""
|
||||
try:
|
||||
with FileLock(self._lock_path, timeout=self._lock_timeout):
|
||||
if not os.path.exists(self._path):
|
||||
return
|
||||
self._write_locked({"cameras": {}})
|
||||
except Timeout:
|
||||
logger.error("Timed out clearing runtime state")
|
||||
except OSError:
|
||||
logger.exception("Failed to clear runtime state")
|
||||
|
||||
def clear_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
|
||||
"""Remove stored entries whose YAML key was just rewritten.
|
||||
|
||||
Each dotted key must be of the form ``cameras.<camera>.<suffix>``.
|
||||
Keys that don't match a tracked topic are ignored.
|
||||
"""
|
||||
to_remove: list[tuple[str, str]] = []
|
||||
for key in dotted_keys:
|
||||
parts = key.split(".")
|
||||
if len(parts) < 3 or parts[0] != "cameras":
|
||||
continue
|
||||
camera = parts[1]
|
||||
suffix = ".".join(parts[2:])
|
||||
topic = self._SUFFIX_TO_TOPIC.get(suffix)
|
||||
if topic is not None:
|
||||
to_remove.append((camera, topic))
|
||||
|
||||
if not to_remove:
|
||||
return
|
||||
|
||||
try:
|
||||
with FileLock(self._lock_path, timeout=self._lock_timeout):
|
||||
data = self._read_locked()
|
||||
cameras = data.get("cameras")
|
||||
if not isinstance(cameras, dict):
|
||||
return
|
||||
changed = False
|
||||
for camera, topic in to_remove:
|
||||
cam = cameras.get(camera)
|
||||
if isinstance(cam, dict) and topic in cam:
|
||||
del cam[topic]
|
||||
changed = True
|
||||
if not cam:
|
||||
del cameras[camera]
|
||||
if changed:
|
||||
self._write_locked(data)
|
||||
except Timeout:
|
||||
logger.error("Timed out clearing runtime state for YAML keys")
|
||||
except OSError:
|
||||
logger.exception("Failed to clear runtime state for YAML keys")
|
||||
|
||||
def _read_locked(self) -> dict[str, Any]:
|
||||
"""Read the JSON file while the FileLock is held.
|
||||
|
||||
Returns ``{}`` on a missing or corrupt file so the caller can write a
|
||||
fresh structure on the next mutation.
|
||||
"""
|
||||
if not os.path.exists(self._path):
|
||||
return {}
|
||||
try:
|
||||
with open(self._path, "r") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.exception(
|
||||
"Failed to read runtime state file %s; starting fresh", self._path
|
||||
)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def _write_locked(self, data: dict[str, Any]) -> None:
|
||||
"""Atomically write the JSON file while the FileLock is held."""
|
||||
tmp_path = f"{self._path}.tmp"
|
||||
with open(tmp_path, "w") as f:
|
||||
json.dump(data, f, indent=2, sort_keys=True)
|
||||
os.replace(tmp_path, self._path)
|
||||
@@ -26,6 +26,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
object_genai = "object_genai"
|
||||
onvif = "onvif"
|
||||
record = "record"
|
||||
refresh = "refresh" # signals the camera maintainer to recycle the camera process
|
||||
remove = "remove" # for removing a camera
|
||||
review = "review"
|
||||
review_genai = "review_genai"
|
||||
@@ -84,8 +85,8 @@ class CameraConfigUpdateSubscriber:
|
||||
self, camera: str, update_type: CameraConfigUpdateEnum, updated_config: Any
|
||||
) -> None:
|
||||
if update_type == CameraConfigUpdateEnum.add:
|
||||
self.config.cameras[camera] = updated_config
|
||||
self.camera_configs[camera] = updated_config
|
||||
shared = self.config.cameras.setdefault(camera, updated_config)
|
||||
self.camera_configs[camera] = shared
|
||||
return
|
||||
elif update_type == CameraConfigUpdateEnum.remove:
|
||||
self.config.cameras.pop(camera, None)
|
||||
|
||||
@@ -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 (
|
||||
@@ -639,6 +680,13 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if self.ffmpeg.hwaccel_args == "auto":
|
||||
self.ffmpeg.hwaccel_args = auto_detect_hwaccel()
|
||||
|
||||
# Resolve global export hwaccel_args so it matches the per-camera
|
||||
# resolution below. Without this, every camera reads as overriding
|
||||
# record.export.hwaccel_args because the global stays "auto" while
|
||||
# the camera value gets resolved to the actual args list.
|
||||
if self.record.export.hwaccel_args == "auto":
|
||||
self.record.export.hwaccel_args = self.ffmpeg.hwaccel_args
|
||||
|
||||
# Populate global audio filters from listen. Existing user-defined
|
||||
# entries for labels not in listen are preserved but unused at runtime.
|
||||
if self.audio.filters is None:
|
||||
@@ -952,6 +1000,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)
|
||||
|
||||
@@ -124,11 +124,24 @@ class ProfileManager:
|
||||
self.config.active_profile = None
|
||||
self._persist_active_profile(None)
|
||||
|
||||
def activate_profile(self, profile_name: Optional[str]) -> Optional[str]:
|
||||
# drop all runtime overrides so they don't replay stale values on restart
|
||||
if self.dispatcher is not None:
|
||||
self.dispatcher.clear_runtime_state()
|
||||
|
||||
def activate_profile(
|
||||
self,
|
||||
profile_name: Optional[str],
|
||||
clear_runtime_overrides: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Activate a profile by name, or deactivate if None.
|
||||
|
||||
Args:
|
||||
profile_name: Profile name to activate, or None to deactivate.
|
||||
clear_runtime_overrides: When True (the default, for user-initiated
|
||||
activations) drop the dispatcher's runtime override file because
|
||||
the layer below changed. Startup callers that are replaying a
|
||||
persisted profile pass False so the runtime state stays
|
||||
available for the subsequent replay step.
|
||||
|
||||
Returns:
|
||||
None on success, or an error message string on failure.
|
||||
@@ -156,6 +169,11 @@ class ProfileManager:
|
||||
|
||||
self.config.active_profile = profile_name
|
||||
self._persist_active_profile(profile_name)
|
||||
|
||||
# a profile switch invalidates the steady-state runtime overrides
|
||||
if clear_runtime_overrides and self.dispatcher is not None:
|
||||
self.dispatcher.clear_runtime_state()
|
||||
|
||||
logger.info(
|
||||
"Profile %s",
|
||||
f"'{profile_name}' activated" if profile_name else "deactivated",
|
||||
|
||||
+51
-2
@@ -5,6 +5,7 @@ frigate.jobs.debug_replay. This module owns only session presence
|
||||
(active), session metadata, and post-session cleanup.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
@@ -40,6 +41,9 @@ from frigate.util.config import find_config_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_SESSION_DURATION_SECONDS = 12 * 60 * 60
|
||||
AUTO_STOP_CHECK_INTERVAL_SECONDS = 60
|
||||
|
||||
|
||||
class DebugReplayManager:
|
||||
"""Owns the lifecycle pointers for a single debug replay session.
|
||||
@@ -58,6 +62,7 @@ class DebugReplayManager:
|
||||
self.clip_path: str | None = None
|
||||
self.start_ts: float | None = None
|
||||
self.end_ts: float | None = None
|
||||
self.session_started_at: float | None = None
|
||||
self._job_state_publisher = JobStatePublisher()
|
||||
|
||||
@property
|
||||
@@ -83,6 +88,7 @@ class DebugReplayManager:
|
||||
self.start_ts = start_ts
|
||||
self.end_ts = end_ts
|
||||
self.clip_path = None
|
||||
self.session_started_at = time.time()
|
||||
|
||||
def mark_session_ready(self, clip_path: str) -> None:
|
||||
"""Record the on-disk clip path after the camera has been published."""
|
||||
@@ -104,6 +110,7 @@ class DebugReplayManager:
|
||||
self.clip_path = None
|
||||
self.start_ts = None
|
||||
self.end_ts = None
|
||||
self.session_started_at = None
|
||||
|
||||
def publish_camera(
|
||||
self,
|
||||
@@ -238,6 +245,10 @@ class DebugReplayManager:
|
||||
zone_dump.setdefault("coordinates", zone_config.coordinates)
|
||||
zones_dict[zone_name] = zone_dump
|
||||
|
||||
# Extract LPR and face recognition configs
|
||||
lpr_dict = source_config.lpr.model_dump()
|
||||
face_recognition_dict = source_config.face_recognition.model_dump()
|
||||
|
||||
# Extract motion config (exclude runtime fields)
|
||||
motion_dict = {}
|
||||
if source_config.motion is not None:
|
||||
@@ -287,8 +298,8 @@ class DebugReplayManager:
|
||||
},
|
||||
"birdseye": {"enabled": False},
|
||||
"audio": {"enabled": False},
|
||||
"lpr": {"enabled": False},
|
||||
"face_recognition": {"enabled": False},
|
||||
"lpr": lpr_dict,
|
||||
"face_recognition": face_recognition_dict,
|
||||
}
|
||||
|
||||
def _cleanup_db(self, camera_name: str) -> None:
|
||||
@@ -347,3 +358,41 @@ def cleanup_replay_cameras() -> None:
|
||||
shutil.rmtree(REPLAY_DIR)
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove replay cache directory: %s", e)
|
||||
|
||||
|
||||
async def debug_replay_auto_stop_watchdog(
|
||||
manager: DebugReplayManager,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> None:
|
||||
"""Auto-stop debug replay sessions that exceed MAX_SESSION_DURATION_SECONDS.
|
||||
|
||||
Backstop against a session left running for days. The cap is intentionally
|
||||
generous so realistic tuning and overnight soak workflows aren't disrupted.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(AUTO_STOP_CHECK_INTERVAL_SECONDS)
|
||||
|
||||
started_at = manager.session_started_at
|
||||
if not manager.active or started_at is None:
|
||||
continue
|
||||
|
||||
if time.time() - started_at < MAX_SESSION_DURATION_SECONDS:
|
||||
continue
|
||||
|
||||
replay_name = manager.replay_camera_name
|
||||
await asyncio.to_thread(
|
||||
manager.stop,
|
||||
frigate_config=frigate_config,
|
||||
config_publisher=config_publisher,
|
||||
)
|
||||
logger.info(
|
||||
"Debug replay auto-stopped after exceeding max session duration of %d hours: %s",
|
||||
MAX_SESSION_DURATION_SECONDS // 3600,
|
||||
replay_name,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Error in debug replay auto-stop watchdog")
|
||||
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any, AsyncGenerator, Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
from pydantic import ValidationError
|
||||
@@ -359,6 +359,41 @@ class GenAIClient:
|
||||
"finish_reason": "error",
|
||||
}
|
||||
|
||||
async def chat_with_tools_stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""Streaming counterpart to `chat_with_tools`.
|
||||
|
||||
Yields ``(kind, value)`` tuples where ``kind`` is one of:
|
||||
- 'content_delta': value is a string fragment of the answer
|
||||
- 'reasoning_delta': value is a string fragment of the reasoning
|
||||
trace (emitted before content for thinking models)
|
||||
- 'stats': value is a usage stats dict
|
||||
- 'message': value is the final dict shape described in
|
||||
`chat_with_tools`
|
||||
|
||||
Argument semantics — including ``enable_thinking`` — match
|
||||
`chat_with_tools`. Providers that don't support streaming should
|
||||
override this and yield an error 'message' event.
|
||||
"""
|
||||
logger.warning(
|
||||
f"{self.__class__.__name__} does not support chat_with_tools_stream. "
|
||||
"This method should be overridden by the provider implementation."
|
||||
)
|
||||
yield (
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def load_providers() -> None:
|
||||
plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Gemini Provider for Frigate AI."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
@@ -14,6 +16,27 @@ from frigate.genai import GenAIClient, register_genai_provider
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _decode_thought_signature(value: Any) -> Optional[bytes]:
|
||||
"""Decode a base64-encoded thought_signature carried across conversation turns."""
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return base64.b64decode(value)
|
||||
except (binascii.Error, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _encode_thought_signature(signature: Optional[bytes]) -> Optional[str]:
|
||||
"""Encode bytes thought_signature as base64 so it survives JSON-friendly transport."""
|
||||
if not signature:
|
||||
return None
|
||||
return base64.b64encode(signature).decode("ascii")
|
||||
|
||||
|
||||
def _stats_from_gemini_usage(usage: Any) -> Optional[dict[str, Any]]:
|
||||
"""Build a stats dict from a Gemini usage_metadata object."""
|
||||
prompt_tokens = getattr(usage, "prompt_token_count", None)
|
||||
@@ -169,11 +192,17 @@ class GeminiClient(GenAIClient):
|
||||
if not isinstance(tc_args, dict):
|
||||
tc_args = {}
|
||||
if tc_name:
|
||||
parts.append(
|
||||
types.Part.from_function_call(
|
||||
name=tc_name, args=tc_args
|
||||
)
|
||||
fc_part = types.Part.from_function_call(
|
||||
name=tc_name, args=tc_args
|
||||
)
|
||||
# Thinking-capable Gemini models require the original
|
||||
# thought_signature to be echoed back on functionCall
|
||||
# parts after a tool response, or the next request
|
||||
# fails with INVALID_ARGUMENT.
|
||||
sig = _decode_thought_signature(tc.get("thought_signature"))
|
||||
if sig:
|
||||
fc_part.thought_signature = sig
|
||||
parts.append(fc_part)
|
||||
if not parts:
|
||||
parts.append(types.Part.from_text(text=" "))
|
||||
gemini_messages.append(types.Content(role="model", parts=parts))
|
||||
@@ -310,6 +339,9 @@ class GeminiClient(GenAIClient):
|
||||
"id": part.function_call.name or "",
|
||||
"name": part.function_call.name or "",
|
||||
"arguments": arguments,
|
||||
"thought_signature": _encode_thought_signature(
|
||||
getattr(part, "thought_signature", None)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -369,11 +401,14 @@ class GeminiClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""
|
||||
Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
Implements streaming function calling/tool usage for Gemini models.
|
||||
``enable_thinking`` is accepted for interface parity; Gemini configures
|
||||
thinking at the model level, so it is ignored here.
|
||||
"""
|
||||
try:
|
||||
# Convert messages to Gemini format
|
||||
@@ -415,11 +450,17 @@ class GeminiClient(GenAIClient):
|
||||
if not isinstance(tc_args, dict):
|
||||
tc_args = {}
|
||||
if tc_name:
|
||||
parts.append(
|
||||
types.Part.from_function_call(
|
||||
name=tc_name, args=tc_args
|
||||
)
|
||||
fc_part = types.Part.from_function_call(
|
||||
name=tc_name, args=tc_args
|
||||
)
|
||||
# Thinking-capable Gemini models require the original
|
||||
# thought_signature to be echoed back on functionCall
|
||||
# parts after a tool response, or the next request
|
||||
# fails with INVALID_ARGUMENT.
|
||||
sig = _decode_thought_signature(tc.get("thought_signature"))
|
||||
if sig:
|
||||
fc_part.thought_signature = sig
|
||||
parts.append(fc_part)
|
||||
if not parts:
|
||||
parts.append(types.Part.from_text(text=" "))
|
||||
gemini_messages.append(types.Content(role="model", parts=parts))
|
||||
@@ -585,6 +626,7 @@ class GeminiClient(GenAIClient):
|
||||
"id": tool_call_id,
|
||||
"name": tool_call_name,
|
||||
"arguments": "",
|
||||
"thought_signature": None,
|
||||
}
|
||||
|
||||
# Accumulate arguments
|
||||
@@ -595,6 +637,13 @@ class GeminiClient(GenAIClient):
|
||||
else str(arguments)
|
||||
)
|
||||
|
||||
# Capture latest thought_signature for this call
|
||||
chunk_sig = getattr(part, "thought_signature", None)
|
||||
if chunk_sig:
|
||||
tool_calls_by_index[found_index][
|
||||
"thought_signature"
|
||||
] = chunk_sig
|
||||
|
||||
# Build final message
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
full_reasoning = "".join(reasoning_parts).strip() or None
|
||||
@@ -615,6 +664,9 @@ class GeminiClient(GenAIClient):
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"arguments": parsed_args,
|
||||
"thought_signature": _encode_thought_signature(
|
||||
tc.get("thought_signature")
|
||||
),
|
||||
}
|
||||
)
|
||||
finish_reason = "tool_calls"
|
||||
|
||||
@@ -309,11 +309,15 @@ class OpenAIClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""
|
||||
Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
Implements streaming function calling/tool usage for OpenAI models.
|
||||
The OpenAI chat completions API does not expose a per-request thinking
|
||||
toggle, so ``enable_thinking`` is accepted for interface parity and
|
||||
ignored.
|
||||
"""
|
||||
try:
|
||||
openai_tool_choice = None
|
||||
|
||||
@@ -518,16 +518,21 @@ def get_tool_definitions(
|
||||
"function": {
|
||||
"name": "get_live_context",
|
||||
"description": (
|
||||
"Get the current live image and detection information for a camera: objects being tracked, "
|
||||
"Get the current live image and detection information for a single camera: objects being tracked, "
|
||||
"zones, timestamps. Use this to understand what is visible in the live view. "
|
||||
"Call this when answering questions about what is happening right now on a specific camera."
|
||||
"Call this when answering questions about what is happening right now on a specific camera. "
|
||||
"Operates on one camera at a time; call the tool again for each additional camera. "
|
||||
"Wildcards and empty values are not accepted."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to get live context for.",
|
||||
"description": (
|
||||
"Exact name of a single camera to get live context for. "
|
||||
"Wildcards (e.g. '*', 'all') and empty strings are not accepted."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["camera"],
|
||||
|
||||
@@ -69,6 +69,14 @@ def build_assistant_message_for_conversation(
|
||||
"name": tc["name"],
|
||||
"arguments": json.dumps(tc.get("arguments") or {}),
|
||||
},
|
||||
# Gemini-only: opaque signature that must be echoed back on
|
||||
# the same functionCall part in the next turn. Other providers
|
||||
# do not set or read this.
|
||||
**(
|
||||
{"thought_signature": tc["thought_signature"]}
|
||||
if tc.get("thought_signature")
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for tc in tool_calls_raw
|
||||
]
|
||||
|
||||
@@ -167,8 +167,9 @@ class DetectorRunner(FrigateProcess):
|
||||
|
||||
# detect and send the output
|
||||
self.start_time.value = datetime.datetime.now().timestamp()
|
||||
mono_start = time.monotonic()
|
||||
detections = object_detector.detect_raw(input_frame)
|
||||
duration = datetime.datetime.now().timestamp() - self.start_time.value
|
||||
duration = time.monotonic() - mono_start
|
||||
frame_manager.close(connection_id)
|
||||
|
||||
if connection_id not in self.outputs:
|
||||
|
||||
@@ -1331,6 +1331,8 @@ class PtzAutoTracker:
|
||||
return self.tracked_object[camera]["region"]
|
||||
|
||||
def autotrack_object(self, camera: str, obj: TrackedObject):
|
||||
if camera not in self.config.cameras:
|
||||
return
|
||||
camera_config = self.config.cameras[camera]
|
||||
|
||||
if camera_config.onvif.autotracking.enabled:
|
||||
|
||||
@@ -579,7 +579,9 @@ class RecordingExporter(threading.Thread):
|
||||
else:
|
||||
chapters_path = self._build_chapter_metadata_file(recordings)
|
||||
chapter_args = (
|
||||
f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else ""
|
||||
f" -i {chapters_path} -map 0 -dn -map_metadata 1"
|
||||
if chapters_path
|
||||
else ""
|
||||
)
|
||||
ffmpeg_cmd = (
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"
|
||||
|
||||
@@ -32,7 +32,7 @@ class StatsEmitter(threading.Thread):
|
||||
self.config = config
|
||||
self.stats_tracking = stats_tracking
|
||||
self.stop_event = stop_event
|
||||
self.hwaccel_errors: list[str] = []
|
||||
self.hwaccel_errors: dict[str, float] = {}
|
||||
self.stats_history: list[dict[str, Any]] = []
|
||||
|
||||
# create communication for stats
|
||||
|
||||
+26
-11
@@ -1,6 +1,7 @@
|
||||
"""Utilities for stats."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
@@ -34,6 +35,10 @@ from frigate.util.services import (
|
||||
)
|
||||
from frigate.version import VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HWACCEL_ERROR_COOLDOWN_SECONDS = 3600
|
||||
|
||||
|
||||
def get_latest_version(config: FrigateConfig) -> str:
|
||||
if not config.telemetry.version_check:
|
||||
@@ -167,7 +172,9 @@ def get_detector_stats(
|
||||
|
||||
|
||||
def get_processing_stats(
|
||||
config: FrigateConfig, stats: dict[str, str], hwaccel_errors: list[str]
|
||||
config: FrigateConfig,
|
||||
stats: dict[str, str],
|
||||
hwaccel_errors: dict[str, float],
|
||||
) -> None:
|
||||
"""Get stats for cpu / gpu."""
|
||||
|
||||
@@ -206,7 +213,9 @@ async def set_bandwidth_stats(config: FrigateConfig, all_stats: dict[str, Any])
|
||||
|
||||
|
||||
async def set_gpu_stats(
|
||||
config: FrigateConfig, all_stats: dict[str, Any], hwaccel_errors: list[str]
|
||||
config: FrigateConfig,
|
||||
all_stats: dict[str, Any],
|
||||
hwaccel_errors: dict[str, float],
|
||||
) -> None:
|
||||
"""Parse GPUs from hwaccel args and use for stats."""
|
||||
hwaccel_args = []
|
||||
@@ -231,12 +240,16 @@ async def set_gpu_stats(
|
||||
|
||||
stats: dict[str, dict] = {}
|
||||
intel_gpu_collected = False
|
||||
now = time.monotonic()
|
||||
|
||||
for args in hwaccel_args:
|
||||
if args in hwaccel_errors:
|
||||
# known erroring args should automatically return as error
|
||||
stats["error-gpu"] = {"gpu": "", "mem": ""}
|
||||
elif "cuvid" in args or "nvidia" in args:
|
||||
last_error = hwaccel_errors.get(args)
|
||||
if last_error is not None:
|
||||
if now - last_error < HWACCEL_ERROR_COOLDOWN_SECONDS:
|
||||
continue
|
||||
hwaccel_errors.pop(args, None)
|
||||
|
||||
if "cuvid" in args or "nvidia" in args:
|
||||
# nvidia GPU
|
||||
nvidia_usage = get_nvidia_gpu_stats()
|
||||
|
||||
@@ -253,7 +266,7 @@ async def set_gpu_stats(
|
||||
|
||||
else:
|
||||
stats["nvidia-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
hwaccel_errors[args] = time.monotonic()
|
||||
elif "nvmpi" in args or "jetson" in args:
|
||||
# nvidia Jetson
|
||||
jetson_usage = get_jetson_stats()
|
||||
@@ -262,7 +275,7 @@ async def set_gpu_stats(
|
||||
stats["jetson-gpu"] = {"vendor": "nvidia", **jetson_usage}
|
||||
else:
|
||||
stats["jetson-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
hwaccel_errors[args] = time.monotonic()
|
||||
elif "qsv" in args or ("vaapi" in args and not is_vaapi_amd_driver()):
|
||||
if not config.telemetry.stats.intel_gpu_stats:
|
||||
continue
|
||||
@@ -280,7 +293,7 @@ async def set_gpu_stats(
|
||||
stats[name] = entry
|
||||
else:
|
||||
stats["intel-gpu"] = {"vendor": "intel", "gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
hwaccel_errors[args] = time.monotonic()
|
||||
elif "vaapi" in args:
|
||||
if not config.telemetry.stats.amd_gpu_stats:
|
||||
continue
|
||||
@@ -292,7 +305,7 @@ async def set_gpu_stats(
|
||||
stats["amd-vaapi"] = {"vendor": "amd", **amd_usage}
|
||||
else:
|
||||
stats["amd-vaapi"] = {"vendor": "amd", "gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
hwaccel_errors[args] = time.monotonic()
|
||||
elif "preset-rk" in args:
|
||||
rga_usage = get_rockchip_gpu_stats()
|
||||
|
||||
@@ -328,7 +341,9 @@ async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> No
|
||||
|
||||
|
||||
def stats_snapshot(
|
||||
config: FrigateConfig, stats_tracking: StatsTrackingTypes, hwaccel_errors: list[str]
|
||||
config: FrigateConfig,
|
||||
stats_tracking: StatsTrackingTypes,
|
||||
hwaccel_errors: dict[str, float],
|
||||
) -> dict[str, Any]:
|
||||
"""Get a snapshot of the current stats that are being tracked."""
|
||||
camera_metrics = stats_tracking["camera_metrics"]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for CameraMaintainer SHM cleanup on camera remove.
|
||||
|
||||
Regression coverage for the case where a camera is removed and then a
|
||||
new camera is added with the same name. Without unlinking the per-frame
|
||||
YUV SHM slots, the maintainer's frame_manager.create call hits
|
||||
FileExistsError and falls back to reopening the existing segment at the
|
||||
*old* size, which the new ffmpeg process then writes mismatched-size
|
||||
frames into.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.camera.maintainer import CameraMaintainer
|
||||
|
||||
|
||||
class TestMaintainerUnlinkFrameSlotsOnRemove(unittest.TestCase):
|
||||
def _make_maintainer(self) -> CameraMaintainer:
|
||||
"""Build a maintainer without invoking __init__ (avoids needing real
|
||||
FrigateConfig, queues, multiprocessing manager, etc.). We're only
|
||||
exercising the SHM-cleanup helper, so the surrounding init is
|
||||
irrelevant."""
|
||||
maintainer = CameraMaintainer.__new__(CameraMaintainer)
|
||||
maintainer.frame_manager = MagicMock()
|
||||
return maintainer
|
||||
|
||||
def test_unlinks_only_segments_with_matching_prefix(self) -> None:
|
||||
maintainer = self._make_maintainer()
|
||||
maintainer.frame_manager.shm_store = {
|
||||
"front_frame0": object(),
|
||||
"front_frame1": object(),
|
||||
"front_frame2": object(),
|
||||
# Different camera; must not be touched.
|
||||
"side_frame0": object(),
|
||||
# Detector input/output buffers are sized by the model and
|
||||
# cached by the long-lived DetectorRunner — must not be
|
||||
# touched even when their owning camera is removed.
|
||||
"front": object(),
|
||||
"out-front": object(),
|
||||
}
|
||||
|
||||
# __name-mangled access from outside the class.
|
||||
maintainer._CameraMaintainer__unlink_camera_frame_slots("front")
|
||||
|
||||
deleted = [c.args[0] for c in maintainer.frame_manager.delete.call_args_list]
|
||||
self.assertEqual(
|
||||
sorted(deleted),
|
||||
["front_frame0", "front_frame1", "front_frame2"],
|
||||
)
|
||||
|
||||
def test_handles_camera_with_no_slots(self) -> None:
|
||||
"""Cameras that were removed before any frame slot was ever
|
||||
created (e.g. cancelled during preparing_clip) should be a no-op."""
|
||||
maintainer = self._make_maintainer()
|
||||
maintainer.frame_manager.shm_store = {"other_frame0": object()}
|
||||
|
||||
maintainer._CameraMaintainer__unlink_camera_frame_slots("front")
|
||||
|
||||
maintainer.frame_manager.delete.assert_not_called()
|
||||
|
||||
def test_swallows_delete_errors(self) -> None:
|
||||
"""Unlink failures shouldn't abort the remove loop — best-effort."""
|
||||
maintainer = self._make_maintainer()
|
||||
maintainer.frame_manager.shm_store = {
|
||||
"front_frame0": object(),
|
||||
"front_frame1": object(),
|
||||
}
|
||||
maintainer.frame_manager.delete.side_effect = OSError("simulated")
|
||||
|
||||
# Both slots are attempted; the OSError on the first doesn't
|
||||
# prevent the second from being tried.
|
||||
with patch("frigate.camera.maintainer.logger"):
|
||||
maintainer._CameraMaintainer__unlink_camera_frame_slots("front")
|
||||
|
||||
self.assertEqual(maintainer.frame_manager.delete.call_count, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for Dispatcher runtime state persistence wiring."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
|
||||
|
||||
def _make_camera_mock(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
enabled_in_config: bool = True,
|
||||
detect_enabled: bool = True,
|
||||
record_enabled: bool = True,
|
||||
record_enabled_in_config: bool = True,
|
||||
snapshots_enabled: bool = True,
|
||||
audio_enabled: bool = True,
|
||||
audio_enabled_in_config: bool = True,
|
||||
) -> MagicMock:
|
||||
"""Build a camera config mock with the fields the in-scope handlers read."""
|
||||
camera = MagicMock()
|
||||
camera.enabled = enabled
|
||||
camera.enabled_in_config = enabled_in_config
|
||||
camera.detect.enabled = detect_enabled
|
||||
camera.motion.enabled = True # avoid the detect→motion side-effect path
|
||||
camera.record.enabled = record_enabled
|
||||
camera.record.enabled_in_config = record_enabled_in_config
|
||||
camera.snapshots.enabled = snapshots_enabled
|
||||
camera.audio.enabled = audio_enabled
|
||||
camera.audio.enabled_in_config = audio_enabled_in_config
|
||||
return camera
|
||||
|
||||
|
||||
def _build_dispatcher(cameras: dict[str, MagicMock]) -> Dispatcher:
|
||||
"""Construct a Dispatcher with the bare-minimum mocks the tests need."""
|
||||
config = MagicMock()
|
||||
config.cameras = cameras
|
||||
config_updater = MagicMock()
|
||||
onvif = MagicMock()
|
||||
ptz_metrics: dict = {}
|
||||
communicators: list = []
|
||||
|
||||
with (
|
||||
patch("frigate.comms.dispatcher.CameraActivityManager"),
|
||||
patch("frigate.comms.dispatcher.AudioActivityManager"),
|
||||
):
|
||||
return Dispatcher(config, config_updater, onvif, ptz_metrics, communicators)
|
||||
|
||||
|
||||
class TestRestoreRuntimeState(unittest.TestCase):
|
||||
"""Verify replay routes through handlers and tolerates missing entries."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.dispatcher = _build_dispatcher(
|
||||
{
|
||||
"front_door": _make_camera_mock(),
|
||||
"back_yard": _make_camera_mock(),
|
||||
}
|
||||
)
|
||||
# Swap each in-scope handler for a MagicMock so we can assert calls
|
||||
# without exercising the handler's own logic.
|
||||
self.handler_mocks: dict[str, MagicMock] = {}
|
||||
for topic in ("enabled", "detect", "snapshots", "recordings", "audio"):
|
||||
mock = MagicMock()
|
||||
self.dispatcher._camera_settings_handlers[topic] = mock
|
||||
self.handler_mocks[topic] = mock
|
||||
|
||||
def test_replays_each_stored_entry_through_its_handler(self) -> None:
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(
|
||||
return_value={
|
||||
"front_door": {"detect": False, "recordings": False},
|
||||
"back_yard": {"audio": False},
|
||||
}
|
||||
),
|
||||
)
|
||||
self.dispatcher.restore_runtime_state()
|
||||
|
||||
self.handler_mocks["detect"].assert_called_once_with("front_door", "OFF")
|
||||
self.handler_mocks["recordings"].assert_called_once_with("front_door", "OFF")
|
||||
self.handler_mocks["audio"].assert_called_once_with("back_yard", "OFF")
|
||||
self.handler_mocks["enabled"].assert_not_called()
|
||||
self.handler_mocks["snapshots"].assert_not_called()
|
||||
|
||||
def test_skips_unknown_cameras(self) -> None:
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(return_value={"removed_cam": {"detect": False}}),
|
||||
)
|
||||
self.dispatcher.restore_runtime_state()
|
||||
for mock in self.handler_mocks.values():
|
||||
mock.assert_not_called()
|
||||
|
||||
def test_skips_unknown_topics(self) -> None:
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(return_value={"front_door": {"some_old_topic": True}}),
|
||||
)
|
||||
self.dispatcher.restore_runtime_state()
|
||||
for mock in self.handler_mocks.values():
|
||||
mock.assert_not_called()
|
||||
|
||||
def test_continues_after_handler_exception(self) -> None:
|
||||
self.handler_mocks["detect"].side_effect = RuntimeError("boom")
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(
|
||||
return_value={
|
||||
"front_door": {"detect": False, "recordings": False},
|
||||
}
|
||||
),
|
||||
)
|
||||
# Must not raise; the recordings handler must still run.
|
||||
self.dispatcher.restore_runtime_state()
|
||||
self.handler_mocks["recordings"].assert_called_once_with("front_door", "OFF")
|
||||
|
||||
def test_true_value_routes_as_on_payload(self) -> None:
|
||||
self.dispatcher._runtime_state = MagicMock(
|
||||
spec=RuntimeStatePersistence,
|
||||
load=MagicMock(return_value={"front_door": {"detect": True}}),
|
||||
)
|
||||
self.dispatcher.restore_runtime_state()
|
||||
self.handler_mocks["detect"].assert_called_once_with("front_door", "ON")
|
||||
|
||||
|
||||
class TestHandlersPersistViaSet(unittest.TestCase):
|
||||
"""Verify each in-scope handler writes to the runtime state on success."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.mkdtemp()
|
||||
self.config_path = os.path.join(self.tmp_dir, "config.yml")
|
||||
with open(self.config_path, "w") as f:
|
||||
f.write("")
|
||||
self._patcher = patch(
|
||||
"frigate.comms.runtime_state.find_config_file",
|
||||
return_value=self.config_path,
|
||||
)
|
||||
self._patcher.start()
|
||||
|
||||
# Start with everything OFF so each ON payload triggers a real change
|
||||
self.cameras = {
|
||||
"front_door": _make_camera_mock(
|
||||
enabled=False,
|
||||
detect_enabled=False,
|
||||
record_enabled=False,
|
||||
snapshots_enabled=False,
|
||||
audio_enabled=False,
|
||||
)
|
||||
}
|
||||
self.dispatcher = _build_dispatcher(self.cameras)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._patcher.stop()
|
||||
for name in os.listdir(self.tmp_dir):
|
||||
os.remove(os.path.join(self.tmp_dir, name))
|
||||
os.rmdir(self.tmp_dir)
|
||||
|
||||
def _stored_state(self) -> dict:
|
||||
return RuntimeStatePersistence().load()
|
||||
|
||||
def test_enabled_handler_persists(self) -> None:
|
||||
self.dispatcher._on_enabled_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"enabled": True}})
|
||||
|
||||
def test_detect_handler_persists(self) -> None:
|
||||
self.dispatcher._on_detect_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"detect": True}})
|
||||
|
||||
def test_recordings_handler_persists(self) -> None:
|
||||
self.dispatcher._on_recordings_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"recordings": True}})
|
||||
|
||||
def test_snapshots_handler_persists(self) -> None:
|
||||
self.dispatcher._on_snapshots_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"snapshots": True}})
|
||||
|
||||
def test_audio_handler_persists(self) -> None:
|
||||
self.dispatcher._on_audio_command("front_door", "ON")
|
||||
self.assertEqual(self._stored_state(), {"front_door": {"audio": True}})
|
||||
|
||||
def test_enabled_in_config_gate_blocks_persistence(self) -> None:
|
||||
"""An ON payload rejected by the gate must not be persisted."""
|
||||
cam = self.cameras["front_door"]
|
||||
cam.enabled_in_config = False
|
||||
cam.record.enabled_in_config = False
|
||||
cam.audio.enabled_in_config = False
|
||||
|
||||
self.dispatcher._on_enabled_command("front_door", "ON")
|
||||
self.dispatcher._on_recordings_command("front_door", "ON")
|
||||
self.dispatcher._on_audio_command("front_door", "ON")
|
||||
|
||||
self.assertEqual(self._stored_state(), {})
|
||||
|
||||
|
||||
class TestClearPassthrough(unittest.TestCase):
|
||||
"""The dispatcher's public clear methods delegate to the store."""
|
||||
|
||||
def test_clear_runtime_state_for_yaml_keys_passthrough(self) -> None:
|
||||
dispatcher = _build_dispatcher({})
|
||||
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
|
||||
keys = ["cameras.front_door.detect.enabled"]
|
||||
dispatcher.clear_runtime_state_for_yaml_keys(keys)
|
||||
dispatcher._runtime_state.clear_for_yaml_keys.assert_called_once_with(keys)
|
||||
|
||||
def test_clear_runtime_state_passthrough(self) -> None:
|
||||
dispatcher = _build_dispatcher({})
|
||||
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
|
||||
dispatcher.clear_runtime_state()
|
||||
dispatcher._runtime_state.clear_all.assert_called_once_with()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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."""
|
||||
@@ -592,6 +727,55 @@ class TestProfileManager(unittest.TestCase):
|
||||
# Should not raise
|
||||
json.dumps(api_base)
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_activate_profile_clears_dispatcher_runtime_state(self, mock_persist):
|
||||
"""User-initiated activation drops runtime overrides (steady-state rule)."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed")
|
||||
dispatcher.clear_runtime_state.assert_called_once_with()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_deactivate_profile_clears_dispatcher_runtime_state(self, mock_persist):
|
||||
"""Deactivating a profile also drops runtime overrides."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed")
|
||||
dispatcher.clear_runtime_state.reset_mock()
|
||||
|
||||
manager.activate_profile(None)
|
||||
dispatcher.clear_runtime_state.assert_called_once_with()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_startup_replay_does_not_clear_runtime_state(self, mock_persist):
|
||||
"""Startup callers pass clear_runtime_overrides=False to preserve state."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed", clear_runtime_overrides=False)
|
||||
dispatcher.clear_runtime_state.assert_not_called()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_update_config_clears_when_active_profile_reapplies(self, mock_persist):
|
||||
"""After /api/config/set, an active-profile re-application drops state."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed")
|
||||
dispatcher.clear_runtime_state.reset_mock()
|
||||
|
||||
new_config = FrigateConfig(**self.config_data)
|
||||
manager.update_config(new_config)
|
||||
dispatcher.clear_runtime_state.assert_called_once_with()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist):
|
||||
"""Plain /api/config/set without a profile doesn't trigger the broad clear."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
# No activate_profile call — config.active_profile is None
|
||||
new_config = FrigateConfig(**self.config_data)
|
||||
manager.update_config(new_config)
|
||||
dispatcher.clear_runtime_state.assert_not_called()
|
||||
|
||||
|
||||
class TestProfilePersistence(unittest.TestCase):
|
||||
"""Test profile persistence to disk."""
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tests for RuntimeStatePersistence."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
|
||||
|
||||
class TestRuntimeStatePersistence(unittest.TestCase):
|
||||
"""Unit tests for the JSON-backed runtime state store."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.mkdtemp()
|
||||
self.config_path = os.path.join(self.tmp_dir, "config.yml")
|
||||
# Touch a placeholder config.yml so find_config_file returns a real path
|
||||
with open(self.config_path, "w") as f:
|
||||
f.write("")
|
||||
self._patcher = patch(
|
||||
"frigate.comms.runtime_state.find_config_file",
|
||||
return_value=self.config_path,
|
||||
)
|
||||
self._patcher.start()
|
||||
self.store = RuntimeStatePersistence()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._patcher.stop()
|
||||
for name in os.listdir(self.tmp_dir):
|
||||
os.remove(os.path.join(self.tmp_dir, name))
|
||||
os.rmdir(self.tmp_dir)
|
||||
|
||||
def test_load_returns_empty_when_file_missing(self) -> None:
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_set_then_load_round_trip(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.set("front_door", "recordings", True)
|
||||
self.store.set("back_yard", "audio", False)
|
||||
|
||||
result = self.store.load()
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"front_door": {"detect": False, "recordings": True},
|
||||
"back_yard": {"audio": False},
|
||||
},
|
||||
)
|
||||
|
||||
def test_set_with_untracked_topic_is_noop(self) -> None:
|
||||
self.store.set("front_door", "ptz_autotracker", True)
|
||||
self.assertEqual(self.store.load(), {})
|
||||
# File should not even be created if no tracked entries were written
|
||||
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
|
||||
self.assertFalse(os.path.exists(runtime_path))
|
||||
|
||||
def test_set_overwrites_previous_value(self) -> None:
|
||||
self.store.set("front_door", "detect", True)
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
|
||||
|
||||
def test_load_returns_empty_when_file_corrupt(self) -> None:
|
||||
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
|
||||
with open(runtime_path, "w") as f:
|
||||
f.write("{not valid json")
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_load_handles_unexpected_top_level_shape(self) -> None:
|
||||
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
|
||||
with open(runtime_path, "w") as f:
|
||||
json.dump(["unexpected", "list"], f)
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_for_yaml_keys_removes_matching_entries(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.set("front_door", "recordings", False)
|
||||
self.store.set("back_yard", "audio", False)
|
||||
|
||||
self.store.clear_for_yaml_keys(
|
||||
[
|
||||
"cameras.front_door.detect.enabled",
|
||||
"cameras.back_yard.audio.enabled",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.store.load(),
|
||||
{"front_door": {"recordings": False}},
|
||||
)
|
||||
|
||||
def test_clear_for_yaml_keys_collapses_empty_camera_dict(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.clear_for_yaml_keys(["cameras.front_door.detect.enabled"])
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_for_yaml_keys_ignores_unrelated_keys(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.clear_for_yaml_keys(
|
||||
[
|
||||
"ui.theme",
|
||||
"go2rtc.streams.x",
|
||||
"cameras.front_door.ffmpeg.inputs",
|
||||
"not_cameras.front_door.detect.enabled",
|
||||
]
|
||||
)
|
||||
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
|
||||
|
||||
def test_clear_for_yaml_keys_handles_empty_iterable(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.clear_for_yaml_keys([])
|
||||
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
|
||||
|
||||
def test_camera_level_enabled_uses_top_level_yaml_key(self) -> None:
|
||||
"""`enabled` topic maps to the camera-level `cameras.<cam>.enabled` key."""
|
||||
self.store.set("front_door", "enabled", False)
|
||||
self.store.clear_for_yaml_keys(["cameras.front_door.enabled"])
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_all_wipes_every_entry(self) -> None:
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.set("front_door", "recordings", True)
|
||||
self.store.set("back_yard", "audio", False)
|
||||
|
||||
self.store.clear_all()
|
||||
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_all_is_safe_when_file_missing(self) -> None:
|
||||
# No prior set() calls — file does not exist
|
||||
self.store.clear_all()
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Tests for SharedMemoryFrameManager cache invalidation.
|
||||
|
||||
Covers the case where a SHM segment is unlinked and recreated at a
|
||||
different size across a camera add/remove cycle while a long-lived
|
||||
in-process cache (e.g. TrackedObjectProcessor) still holds a ref to
|
||||
the old, smaller segment.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.util.image import SharedMemoryFrameManager
|
||||
|
||||
|
||||
def _fake_shm(size: int) -> SimpleNamespace:
|
||||
"""A minimal stand-in for UntrackedSharedMemory with .size and .buf."""
|
||||
return SimpleNamespace(size=size, buf=bytearray(size), close=lambda: None)
|
||||
|
||||
|
||||
class TestSharedMemoryFrameManagerGet(unittest.TestCase):
|
||||
def test_get_reopens_when_cached_segment_is_smaller_than_shape(self) -> None:
|
||||
"""A cached ref to an older smaller segment must be dropped and the
|
||||
current (correctly sized) segment reopened. Without this, np.ndarray
|
||||
would raise "buffer is too small for requested array" when the
|
||||
in-memory cache pointed at an old SHM after a same-name resize."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
small = _fake_shm(size=100)
|
||||
current = _fake_shm(size=2_500)
|
||||
manager.shm_store["cam_frame0"] = small
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=current):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertEqual(arr.shape, (50, 50))
|
||||
self.assertIs(manager.shm_store["cam_frame0"], current)
|
||||
|
||||
def test_get_reopens_when_cached_segment_is_larger_than_shape(self) -> None:
|
||||
"""Symmetric to the smaller-cache case: when detect resolution drops,
|
||||
the SHM is unlinked and recreated at a smaller size. A cached ref to
|
||||
the old, larger segment still satisfies any size check but points at
|
||||
an orphaned inode whose stale bytes get reinterpreted at the new
|
||||
shape — producing miscolored, distorted YUV frames downstream. Drop
|
||||
the cache so we reopen by name and bind to the current segment."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
old_large = _fake_shm(size=10_000)
|
||||
current = _fake_shm(size=2_500)
|
||||
manager.shm_store["cam_frame0"] = old_large
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=current):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertEqual(arr.shape, (50, 50))
|
||||
self.assertIs(manager.shm_store["cam_frame0"], current)
|
||||
|
||||
def test_get_keeps_cached_segment_when_size_matches(self) -> None:
|
||||
"""Don't pay the reopen cost when the cached ref is the right size."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
cached = _fake_shm(size=2_500)
|
||||
manager.shm_store["cam_frame0"] = cached
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls:
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
untracked_shm_cls.assert_not_called()
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertIs(manager.shm_store["cam_frame0"], cached)
|
||||
|
||||
def test_get_opens_fresh_when_no_cache_entry(self) -> None:
|
||||
manager = SharedMemoryFrameManager()
|
||||
fresh = _fake_shm(size=2_500)
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=fresh):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertIs(manager.shm_store["cam_frame0"], fresh)
|
||||
|
||||
def test_get_returns_none_when_segment_missing(self) -> None:
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
with patch(
|
||||
"frigate.util.image.UntrackedSharedMemory",
|
||||
side_effect=FileNotFoundError,
|
||||
):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNone(arr)
|
||||
|
||||
def test_get_returns_none_when_reopened_segment_is_still_too_small(self) -> None:
|
||||
"""Race during a same-name SHM recreate: cache is stale, we reopen
|
||||
by name, but the maintainer hasn't allocated the new segment yet —
|
||||
the reopened ref is also too small. Skip the frame (return None)
|
||||
rather than crash on np.ndarray."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
small_cached = _fake_shm(size=100)
|
||||
still_small_after_reopen = _fake_shm(size=100)
|
||||
manager.shm_store["cam_frame0"] = small_cached
|
||||
|
||||
with patch(
|
||||
"frigate.util.image.UntrackedSharedMemory",
|
||||
return_value=still_small_after_reopen,
|
||||
):
|
||||
arr = manager.get("cam_frame0", (50, 50))
|
||||
|
||||
self.assertIsNone(arr)
|
||||
# Don't cache the too-small reopened ref — next call will re-open
|
||||
# once the maintainer has finished recreating the segment.
|
||||
self.assertNotIn("cam_frame0", manager.shm_store)
|
||||
|
||||
def test_get_handles_n_dimensional_shape(self) -> None:
|
||||
"""np.prod must be used (not raw multiplication) for tuple shapes."""
|
||||
manager = SharedMemoryFrameManager()
|
||||
# YUV-shaped frame: (height * 3/2, width) for 1920x1080 = 3,110,400
|
||||
big_enough = _fake_shm(size=3_110_400)
|
||||
manager.shm_store["cam_frame0"] = big_enough
|
||||
|
||||
with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls:
|
||||
arr = manager.get("cam_frame0", (1620, 1920))
|
||||
untracked_shm_cls.assert_not_called()
|
||||
|
||||
self.assertIsNotNone(arr)
|
||||
self.assertEqual(arr.shape, (1620, 1920))
|
||||
|
||||
|
||||
class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase):
|
||||
"""End-to-end-style: simulates the full unlink-and-recreate cycle."""
|
||||
|
||||
def test_segment_grows_then_get_succeeds(self) -> None:
|
||||
manager = SharedMemoryFrameManager()
|
||||
|
||||
# Phase 1: existing camera at 320x240 YUV — 320 * 240 * 1.5 = 115_200
|
||||
small = _fake_shm(size=115_200)
|
||||
manager.shm_store["cam_frame0"] = small
|
||||
arr_small = np.ndarray((360, 320), dtype=np.uint8, buffer=small.buf)
|
||||
self.assertEqual(arr_small.shape, (360, 320))
|
||||
|
||||
# Phase 2: restart at 1920x1080 — new SHM segment, larger size.
|
||||
large = _fake_shm(size=3_110_400)
|
||||
with patch("frigate.util.image.UntrackedSharedMemory", return_value=large):
|
||||
arr_large = manager.get("cam_frame0", (1620, 1920))
|
||||
|
||||
self.assertIsNotNone(arr_large)
|
||||
self.assertEqual(arr_large.shape, (1620, 1920))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -788,6 +788,45 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[
|
||||
)
|
||||
camera_config.objects = new_objects
|
||||
|
||||
elif section == "detect":
|
||||
# apply detect first so frame_shape reflects the new resolution
|
||||
# before we rebuild mask-dependent runtime configs below
|
||||
merged = deep_merge(current.model_dump(), update, override=True)
|
||||
camera_config.detect = current.__class__.model_validate(merged)
|
||||
|
||||
new_frame_shape = camera_config.frame_shape
|
||||
|
||||
# rebuild motion's rasterized_mask at the new frame_shape
|
||||
if camera_config.motion is not None:
|
||||
camera_config.motion = RuntimeMotionConfig(
|
||||
frame_shape=new_frame_shape,
|
||||
**camera_config.motion.model_dump(exclude_unset=True),
|
||||
)
|
||||
|
||||
# rebuild per-object filter masks at the new frame_shape
|
||||
for obj_name, filt in camera_config.objects.filters.items():
|
||||
merged_mask = dict(filt.mask)
|
||||
if camera_config.objects.mask:
|
||||
for gid, gmask in camera_config.objects.mask.items():
|
||||
merged_mask[f"global_{gid}"] = gmask
|
||||
|
||||
camera_config.objects.filters[obj_name] = RuntimeFilterConfig(
|
||||
frame_shape=new_frame_shape,
|
||||
mask=merged_mask,
|
||||
**filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}),
|
||||
)
|
||||
|
||||
# Regenerate zone contours and per-zone filter masks at the new
|
||||
# frame_shape so zone outlines and membership stay relative
|
||||
for zone in camera_config.zones.values():
|
||||
if zone.filters:
|
||||
for zone_obj_name, zone_filter in zone.filters.items():
|
||||
zone.filters[zone_obj_name] = RuntimeFilterConfig(
|
||||
frame_shape=new_frame_shape,
|
||||
**zone_filter.model_dump(exclude_unset=True),
|
||||
)
|
||||
zone.generate_contour(new_frame_shape)
|
||||
|
||||
else:
|
||||
merged = deep_merge(current.model_dump(), update, override=True)
|
||||
setattr(camera_config, section, current.__class__.model_validate(merged))
|
||||
|
||||
+18
-3
@@ -1089,10 +1089,25 @@ class SharedMemoryFrameManager(FrameManager):
|
||||
|
||||
def get(self, name: str, shape) -> Optional[np.ndarray]:
|
||||
try:
|
||||
if name in self.shm_store:
|
||||
shm = self.shm_store[name]
|
||||
else:
|
||||
required = int(np.prod(shape))
|
||||
shm = self.shm_store.get(name)
|
||||
if shm is not None and shm.size != required:
|
||||
# stale cached ref from a same-name recreate — drop and reopen
|
||||
try:
|
||||
shm.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.shm_store.pop(name, None)
|
||||
shm = None
|
||||
if shm is None:
|
||||
shm = UntrackedSharedMemory(name=name)
|
||||
if shm.size != required:
|
||||
# mid-recreate: OS segment doesn't match shape yet; skip
|
||||
try:
|
||||
shm.close()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
self.shm_store[name] = shm
|
||||
return np.ndarray(shape, dtype=np.uint8, buffer=shm.buf)
|
||||
except FileNotFoundError:
|
||||
|
||||
@@ -416,6 +416,11 @@ def get_intel_gpu_stats(
|
||||
|
||||
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
|
||||
if not snapshot_a:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: no DRM fdinfo entries found"
|
||||
"%s. Check that /proc is readable and the i915/xe driver is loaded",
|
||||
f" for pdev {target_pdev}" if target_pdev else "",
|
||||
)
|
||||
return None
|
||||
|
||||
start = time.monotonic()
|
||||
@@ -424,6 +429,9 @@ def get_intel_gpu_stats(
|
||||
|
||||
snapshot_b = _read_intel_drm_fdinfo(target_pdev)
|
||||
if not snapshot_b or elapsed_ns <= 0:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: second DRM fdinfo sample was empty"
|
||||
)
|
||||
return None
|
||||
|
||||
def _new_engine_pct() -> dict[str, float]:
|
||||
@@ -464,6 +472,10 @@ def get_intel_gpu_stats(
|
||||
pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total
|
||||
|
||||
if not per_pdev_engine_pct:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: no per-engine counters available "
|
||||
"(i915 requires kernel >= 5.19)"
|
||||
)
|
||||
return None
|
||||
|
||||
names = intel_gpu_name_resolver.get_names()
|
||||
@@ -478,7 +490,7 @@ def get_intel_gpu_stats(
|
||||
overall_pct = min(100.0, compute_pct + dec_pct)
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"name": names.get(pdev) or f"Intel GPU {pdev}",
|
||||
"name": names.get(pdev) or "Intel iGPU",
|
||||
"vendor": "intel",
|
||||
"gpu": f"{round(overall_pct, 2)}%",
|
||||
"mem": "-%",
|
||||
|
||||
Generated
+3
-3
@@ -13575,9 +13575,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"en": "English (English)",
|
||||
"es": "Español (Spanish)",
|
||||
"zhCN": "简体中文 (Simplified Chinese)",
|
||||
"zhHant": "繁體中文 (Traditional Chinese)",
|
||||
"hi": "हिन्दी (Hindi)",
|
||||
"fr": "Français (French)",
|
||||
"ar": "العربية (Arabic)",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -28,5 +28,8 @@
|
||||
"detectRequired": "At least one input stream must be assigned the 'detect' role.",
|
||||
"hwaccelDetectOnly": "Only the input stream with the detect role can define hardware acceleration arguments."
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"dimensionMustBeEven": "Must be an even number."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,20 +475,29 @@
|
||||
"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",
|
||||
"friendlyName": {
|
||||
"edit": "Edit camera display name",
|
||||
"title": "Edit Display Name",
|
||||
"description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
|
||||
"rename": "Rename"
|
||||
"details": {
|
||||
"edit": "Edit camera details",
|
||||
"title": "Edit Camera Details",
|
||||
"description": "Update the display name and external URL used for this camera throughout the Frigate UI.",
|
||||
"friendlyNameLabel": "Display Name",
|
||||
"friendlyNameHelp": "Friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
|
||||
"webuiUrlLabel": "Camera Web UI URL",
|
||||
"webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.",
|
||||
"webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com)."
|
||||
}
|
||||
},
|
||||
"cameraConfig": {
|
||||
@@ -523,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",
|
||||
@@ -1396,6 +1405,17 @@
|
||||
"namePlaceholder": "e.g., Wife's Car",
|
||||
"platePlaceholder": "Plate number or regex"
|
||||
},
|
||||
"liveStreams": {
|
||||
"streamNameLabel": "Stream name",
|
||||
"streamNamePlaceholder": "e.g., Main HD Stream",
|
||||
"go2rtcStreamLabel": "go2rtc stream",
|
||||
"go2rtcStreamPlaceholder": "Select a go2rtc stream",
|
||||
"go2rtcStreamSearch": "Search or enter a stream name…",
|
||||
"noGo2rtcStreams": "No go2rtc streams configured",
|
||||
"availableStreams": "Available streams",
|
||||
"useCustom": "Use \"{{value}}\"",
|
||||
"addStream": "Add stream"
|
||||
},
|
||||
"timezone": {
|
||||
"defaultOption": "Use browser timezone"
|
||||
},
|
||||
@@ -1539,6 +1559,9 @@
|
||||
"builtIn": "Built-in Models",
|
||||
"genaiProviders": "GenAI Providers"
|
||||
},
|
||||
"semanticSearchModelSize": {
|
||||
"notApplicable": "Not applicable for GenAI providers"
|
||||
},
|
||||
"review": {
|
||||
"title": "Review Settings"
|
||||
},
|
||||
@@ -1787,7 +1810,9 @@
|
||||
},
|
||||
"detect": {
|
||||
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
|
||||
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function."
|
||||
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
|
||||
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
|
||||
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image."
|
||||
},
|
||||
"objects": {
|
||||
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createContext } from "react";
|
||||
import type { FieldConditionalMessage } from "./section-configs/types";
|
||||
|
||||
// Provides currently-active field messages to FieldTemplate without going
|
||||
// through RJSF's per-field uiSchema. RJSF caches state.uiSchema across renders
|
||||
// in a way that can leave stale ui:messages attached to a field when the
|
||||
// triggering condition flips back to false (see processPendingChange in
|
||||
// @rjsf/core Form.js — formData is updated immediately, uiSchema is not).
|
||||
// useContext re-runs consumers directly on provider value change, sidestepping
|
||||
// that staleness.
|
||||
export const FieldMessagesContext = createContext<FieldConditionalMessage[]>(
|
||||
[],
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createContext } from "react";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
|
||||
// Mirrors the current section's in-flight form data so widgets can react
|
||||
// to changes that RJSF wouldn't otherwise re-render them for. RJSF's
|
||||
// Form memoizes SchemaField via deep equality and, in some transitions
|
||||
// (notably reverting a field to its saved value), can skip re-rendering
|
||||
// a widget even though the form data it depends on changed. useContext
|
||||
// re-runs consumers directly on every provider value update, sidestepping
|
||||
// that.
|
||||
export const LiveFormDataContext = createContext<ConfigSectionData | null>(
|
||||
null,
|
||||
);
|
||||
@@ -11,6 +11,50 @@ const detect: SectionConfigOverrides = {
|
||||
condition: (ctx) =>
|
||||
ctx.level === "camera" && ctx.formData?.enabled === false,
|
||||
},
|
||||
{
|
||||
key: "detect-resolution-not-multiple-of-four",
|
||||
messageKey: "configMessages.detect.resolutionShouldBeMultipleOfFour",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
const width = ctx.formData?.width as number | null | undefined;
|
||||
const height = ctx.formData?.height as number | null | undefined;
|
||||
const isEvenButNotFour = (v: unknown) =>
|
||||
typeof v === "number" && v % 2 === 0 && v % 4 !== 0;
|
||||
return isEvenButNotFour(width) || isEvenButNotFour(height);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "detect-aspect-ratio-mismatch",
|
||||
messageKey: "configMessages.detect.aspectRatioMismatch",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
const newWidth = ctx.formData?.width as number | null | undefined;
|
||||
const newHeight = ctx.formData?.height as number | null | undefined;
|
||||
if (typeof newWidth !== "number" || typeof newHeight !== "number") {
|
||||
return false;
|
||||
}
|
||||
const saved =
|
||||
ctx.level === "camera"
|
||||
? ctx.fullCameraConfig?.detect
|
||||
: ctx.fullConfig?.detect;
|
||||
const savedWidth = saved?.width;
|
||||
const savedHeight = saved?.height;
|
||||
if (
|
||||
typeof savedWidth !== "number" ||
|
||||
typeof savedHeight !== "number" ||
|
||||
savedWidth <= 0 ||
|
||||
savedHeight <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (newWidth === savedWidth && newHeight === savedHeight) {
|
||||
return false;
|
||||
}
|
||||
const newRatio = newWidth / newHeight;
|
||||
const savedRatio = savedWidth / savedHeight;
|
||||
return Math.abs(newRatio - savedRatio) > 0.01;
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldMessages: [
|
||||
{
|
||||
@@ -72,6 +116,25 @@ const detect: SectionConfigOverrides = {
|
||||
"max_disappeared",
|
||||
],
|
||||
},
|
||||
replay: {
|
||||
restartRequired: [],
|
||||
fieldOrder: ["width", "height", "fps"],
|
||||
fieldGroups: {
|
||||
resolution: ["width", "height", "fps"],
|
||||
},
|
||||
hiddenFields: [
|
||||
"enabled",
|
||||
"enabled_in_config",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
"stationary",
|
||||
"interval",
|
||||
"threshold",
|
||||
"max_frames",
|
||||
],
|
||||
advancedFields: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default detect;
|
||||
|
||||
@@ -4,17 +4,26 @@ const live: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/live",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["stream_name", "height", "quality"],
|
||||
fieldOrder: ["streams", "height", "quality"],
|
||||
fieldGroups: {},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["height", "quality"],
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["stream_name", "height", "quality"],
|
||||
restartRequired: ["streams", "height", "quality"],
|
||||
hiddenFields: ["streams"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["height", "quality"],
|
||||
uiSchema: {
|
||||
streams: {
|
||||
"ui:field": "LiveStreamsField",
|
||||
"ui:options": {
|
||||
label: false,
|
||||
suppressDescription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ const semanticSearch: SectionConfigOverrides = {
|
||||
"ui:widget": "semanticSearchModel",
|
||||
},
|
||||
model_size: {
|
||||
"ui:widget": "semanticSearchModelSize",
|
||||
"ui:options": { size: "xs", enumI18nPrefix: "modelSize" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import type { JsonObject } from "@/types/configForm";
|
||||
|
||||
export function validateDetectDimensions(
|
||||
formData: unknown,
|
||||
errors: FormValidation,
|
||||
t: TFunction,
|
||||
): FormValidation {
|
||||
if (!isJsonObject(formData as JsonObject)) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const data = formData as JsonObject;
|
||||
const width = data.width;
|
||||
const height = data.height;
|
||||
|
||||
const widthErrors = errors.width as
|
||||
| { addError?: (message: string) => void }
|
||||
| undefined;
|
||||
const heightErrors = errors.height as
|
||||
| { addError?: (message: string) => void }
|
||||
| undefined;
|
||||
|
||||
const message = t("detect.dimensionMustBeEven", { ns: "config/validation" });
|
||||
|
||||
if (typeof width === "number" && width % 2 !== 0) {
|
||||
widthErrors?.addError?.(message);
|
||||
}
|
||||
if (typeof height === "number" && height % 2 !== 0) {
|
||||
heightErrors?.addError?.(message);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { validateDetectDimensions } from "./detect";
|
||||
import { validateFfmpegInputRoles } from "./ffmpeg";
|
||||
import { validateProxyRoleHeader } from "./proxy";
|
||||
|
||||
@@ -19,6 +20,10 @@ export function getSectionValidation({
|
||||
level,
|
||||
t,
|
||||
}: SectionValidationOptions): SectionValidation | undefined {
|
||||
if (sectionPath === "detect") {
|
||||
return (formData, errors) => validateDetectDimensions(formData, errors, t);
|
||||
}
|
||||
|
||||
if (sectionPath === "ffmpeg" && level === "camera") {
|
||||
return (formData, errors) => validateFfmpegInputRoles(formData, errors, t);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ import type {
|
||||
} from "../section-configs/types";
|
||||
import { useConfigMessages } from "@/hooks/use-config-messages";
|
||||
import { ConfigMessageBanner } from "../ConfigMessageBanner";
|
||||
import { FieldMessagesContext } from "../FieldMessagesContext";
|
||||
import { LiveFormDataContext } from "../LiveFormDataContext";
|
||||
|
||||
export interface SectionConfig {
|
||||
/** Field ordering within the section */
|
||||
@@ -627,44 +629,6 @@ export function ConfigSection({
|
||||
messageContext,
|
||||
);
|
||||
|
||||
// Merge field-level conditional messages into uiSchema
|
||||
const effectiveUiSchema = useMemo(() => {
|
||||
if (activeFieldMessages.length === 0) return sectionConfig.uiSchema;
|
||||
const merged = { ...(sectionConfig.uiSchema ?? {}) };
|
||||
for (const msg of activeFieldMessages) {
|
||||
const segments = msg.field.split(".");
|
||||
// Navigate to the nested uiSchema node, shallow-cloning along the way
|
||||
let node = merged;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg = segments[i];
|
||||
node[seg] = { ...(node[seg] as Record<string, unknown>) };
|
||||
node = node[seg] as Record<string, unknown>;
|
||||
}
|
||||
const leafKey = segments[segments.length - 1];
|
||||
const existing = node[leafKey] as Record<string, unknown> | undefined;
|
||||
const existingMessages = ((existing?.["ui:messages"] as unknown[]) ??
|
||||
[]) as Array<{
|
||||
key: string;
|
||||
messageKey: string;
|
||||
severity: string;
|
||||
position?: string;
|
||||
}>;
|
||||
node[leafKey] = {
|
||||
...existing,
|
||||
"ui:messages": [
|
||||
...existingMessages,
|
||||
{
|
||||
key: msg.key,
|
||||
messageKey: msg.messageKey,
|
||||
severity: msg.severity,
|
||||
position: msg.position ?? "before",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}, [sectionConfig.uiSchema, activeFieldMessages]);
|
||||
|
||||
const currentOverrides = useMemo(() => {
|
||||
if (!currentFormData || typeof currentFormData !== "object") {
|
||||
return undefined;
|
||||
@@ -1034,59 +998,65 @@ export function ConfigSection({
|
||||
const sectionContent = (
|
||||
<div className="space-y-6">
|
||||
<ConfigMessageBanner messages={activeMessages} />
|
||||
<ConfigForm
|
||||
key={formKey}
|
||||
schema={modifiedSchema}
|
||||
formData={currentFormData}
|
||||
onChange={handleChange}
|
||||
onValidationChange={setHasValidationErrors}
|
||||
fieldOrder={sectionConfig.fieldOrder}
|
||||
fieldGroups={sectionConfig.fieldGroups}
|
||||
hiddenFields={effectiveHiddenFields}
|
||||
advancedFields={sectionConfig.advancedFields}
|
||||
liveValidate={sectionConfig.liveValidate}
|
||||
uiSchema={effectiveUiSchema}
|
||||
disabled={disabled || isSaving}
|
||||
readonly={readonly}
|
||||
showSubmit={false}
|
||||
i18nNamespace={configNamespace}
|
||||
customValidate={customValidate}
|
||||
formContext={{
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
hasChanges,
|
||||
extraHasChanges,
|
||||
setExtraHasChanges,
|
||||
overrides: uiOverrides as JsonValue | undefined,
|
||||
formData: currentFormData as ConfigSectionData,
|
||||
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||
// For widgets that need access to full camera config (e.g., zone names)
|
||||
fullCameraConfig:
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
fullConfig: config,
|
||||
// When rendering camera-level sections, provide the section path so
|
||||
// field templates can look up keys under the `config/cameras` namespace
|
||||
// When using a consolidated global namespace, keys are nested
|
||||
// under the section name (e.g., `audio.label`) so provide the
|
||||
// section prefix to templates so they can attempt `${section}.${field}` lookups.
|
||||
sectionI18nPrefix: sectionPath,
|
||||
t,
|
||||
renderers: wrappedRenderers,
|
||||
sectionDocs: sectionConfig.sectionDocs,
|
||||
fieldDocs: sectionConfig.fieldDocs,
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
restartRequired: sectionConfig.restartRequired,
|
||||
requiresRestart,
|
||||
isProfile: !!profileName,
|
||||
}}
|
||||
/>
|
||||
<FieldMessagesContext.Provider value={activeFieldMessages}>
|
||||
<LiveFormDataContext.Provider
|
||||
value={(currentFormData as ConfigSectionData | null) ?? null}
|
||||
>
|
||||
<ConfigForm
|
||||
key={formKey}
|
||||
schema={modifiedSchema}
|
||||
formData={currentFormData}
|
||||
onChange={handleChange}
|
||||
onValidationChange={setHasValidationErrors}
|
||||
fieldOrder={sectionConfig.fieldOrder}
|
||||
fieldGroups={sectionConfig.fieldGroups}
|
||||
hiddenFields={effectiveHiddenFields}
|
||||
advancedFields={sectionConfig.advancedFields}
|
||||
liveValidate={sectionConfig.liveValidate}
|
||||
uiSchema={sectionConfig.uiSchema}
|
||||
disabled={disabled || isSaving}
|
||||
readonly={readonly}
|
||||
showSubmit={false}
|
||||
i18nNamespace={configNamespace}
|
||||
customValidate={customValidate}
|
||||
formContext={{
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
hasChanges,
|
||||
extraHasChanges,
|
||||
setExtraHasChanges,
|
||||
overrides: uiOverrides as JsonValue | undefined,
|
||||
formData: currentFormData as ConfigSectionData,
|
||||
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||
// For widgets that need access to full camera config (e.g., zone names)
|
||||
fullCameraConfig:
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
fullConfig: config,
|
||||
// When rendering camera-level sections, provide the section path so
|
||||
// field templates can look up keys under the `config/cameras` namespace
|
||||
// When using a consolidated global namespace, keys are nested
|
||||
// under the section name (e.g., `audio.label`) so provide the
|
||||
// section prefix to templates so they can attempt `${section}.${field}` lookups.
|
||||
sectionI18nPrefix: sectionPath,
|
||||
t,
|
||||
renderers: wrappedRenderers,
|
||||
sectionDocs: sectionConfig.sectionDocs,
|
||||
fieldDocs: sectionConfig.fieldDocs,
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
restartRequired: sectionConfig.restartRequired,
|
||||
requiresRestart,
|
||||
isProfile: !!profileName,
|
||||
}}
|
||||
/>
|
||||
</LiveFormDataContext.Provider>
|
||||
</FieldMessagesContext.Provider>
|
||||
|
||||
{!embedded && (
|
||||
<div
|
||||
@@ -1288,7 +1258,12 @@ export function ConfigSection({
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Heading as="h4">{title}</Heading>
|
||||
<Heading
|
||||
as="h4"
|
||||
className={level === "replay" ? "text-base" : undefined}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
(profileOverridesSection || isOverridden) &&
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, ChevronsUpDown, Plus } from "lucide-react";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import get from "lodash/get";
|
||||
import { isSubtreeModified } from "../utils";
|
||||
|
||||
type LiveStreamsData = Record<string, string>;
|
||||
|
||||
type StreamValueComboboxProps = {
|
||||
id: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
onChange: (next: string) => void;
|
||||
};
|
||||
|
||||
function StreamValueCombobox({
|
||||
id,
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
}: StreamValueComboboxProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const trimmedSearch = searchValue.trim();
|
||||
const matchesOption = useMemo(
|
||||
() => options.some((o) => o.toLowerCase() === trimmedSearch.toLowerCase()),
|
||||
[options, trimmedSearch],
|
||||
);
|
||||
const showCustomOption = trimmedSearch.length > 0 && !matchesOption;
|
||||
|
||||
const commit = (next: string) => {
|
||||
onChange(next);
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const placeholder = t("configForm.liveStreams.go2rtcStreamPlaceholder", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const searchPlaceholder = t("configForm.liveStreams.go2rtcStreamSearch", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const noStreams = t("configForm.liveStreams.noGo2rtcStreams", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const availableHeading = t("configForm.liveStreams.availableStreams", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setSearchValue("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{value || placeholder}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && showCustomOption) {
|
||||
e.preventDefault();
|
||||
commit(trimmedSearch);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
{showCustomOption && (
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={trimmedSearch}
|
||||
onSelect={() => commit(trimmedSearch)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("configForm.liveStreams.useCustom", {
|
||||
ns: "views/settings",
|
||||
value: trimmedSearch,
|
||||
})}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{options.length > 0 ? (
|
||||
<CommandGroup heading={availableHeading}>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option}
|
||||
onSelect={() => commit(option)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{option}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : !showCustomOption ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{noStreams}
|
||||
</div>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveStreamsField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
(formContext?.level === "camera" ? "config/cameras" : "config/global");
|
||||
const { t: fallbackT } = useTranslation(["common", configNamespace]);
|
||||
const t = formContext?.t ?? fallbackT;
|
||||
|
||||
const data: LiveStreamsData = useMemo(() => {
|
||||
if (!formData || typeof formData !== "object" || Array.isArray(formData)) {
|
||||
return {};
|
||||
}
|
||||
return formData as LiveStreamsData;
|
||||
}, [formData]);
|
||||
|
||||
const entries = useMemo(() => Object.entries(data), [data]);
|
||||
|
||||
const id = idSchema?.$id ?? props.name;
|
||||
const sectionPrefix = formContext?.sectionI18nPrefix;
|
||||
|
||||
const title =
|
||||
t(`${sectionPrefix}.${id}.label`) ?? (schema as RJSFSchema).title;
|
||||
const description =
|
||||
t(`${sectionPrefix}.${id}.description`) ??
|
||||
(schema as RJSFSchema).description;
|
||||
|
||||
const go2rtcStreamNames = useMemo<string[]>(() => {
|
||||
const streams = formContext?.fullConfig?.go2rtc?.streams;
|
||||
if (!streams || typeof streams !== "object") return [];
|
||||
return Object.keys(streams).sort();
|
||||
}, [formContext?.fullConfig?.go2rtc?.streams]);
|
||||
|
||||
const emptyPath = useMemo(() => [] as FieldPathList, []);
|
||||
const fieldPath =
|
||||
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
|
||||
emptyPath;
|
||||
|
||||
const isModified = useMemo(() => {
|
||||
const baselineRoot = formContext?.baselineFormData;
|
||||
const baselineValue = baselineRoot
|
||||
? get(baselineRoot, fieldPath)
|
||||
: undefined;
|
||||
return isSubtreeModified(
|
||||
data,
|
||||
baselineValue,
|
||||
formContext?.overrides,
|
||||
fieldPath,
|
||||
formContext?.formData,
|
||||
);
|
||||
}, [fieldPath, formContext, data]);
|
||||
|
||||
const handleAddEntry = useCallback(() => {
|
||||
const next = { ...data, "": "" };
|
||||
onChange(next, fieldPath);
|
||||
}, [data, fieldPath, onChange]);
|
||||
|
||||
const handleRemoveEntry = useCallback(
|
||||
(key: string) => {
|
||||
const next = { ...data };
|
||||
delete next[key];
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
const handleRenameKey = useCallback(
|
||||
(oldKey: string, newKey: string) => {
|
||||
if (oldKey === newKey) return;
|
||||
const next: LiveStreamsData = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (k === oldKey) {
|
||||
next[newKey] = v;
|
||||
} else {
|
||||
next[k] = v;
|
||||
}
|
||||
}
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
const handleUpdateValue = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const next = { ...data, [key]: value };
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
const baseId = idSchema?.$id || "live_streams";
|
||||
const deleteLabel = t("button.delete", {
|
||||
ns: "common",
|
||||
defaultValue: "Delete",
|
||||
});
|
||||
const streamNameLabel = t("configForm.liveStreams.streamNameLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const streamNamePlaceholder = t(
|
||||
"configForm.liveStreams.streamNamePlaceholder",
|
||||
{ ns: "views/settings" },
|
||||
);
|
||||
const go2rtcStreamLabel = t("configForm.liveStreams.go2rtcStreamLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const addStreamLabel = t("configForm.liveStreams.addStream", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className={cn("text-sm", isModified && "text-unsaved")}>
|
||||
{title}
|
||||
</CardTitle>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
{entries.map(([key, value], entryIndex) => {
|
||||
const entryId = `${baseId}-${entryIndex}`;
|
||||
return (
|
||||
<div
|
||||
key={entryIndex}
|
||||
className="grid grid-cols-12 items-end gap-2 rounded-md border p-3"
|
||||
>
|
||||
<div className="col-span-12 space-y-2 md:col-span-5">
|
||||
<Label htmlFor={`${entryId}-key`}>{streamNameLabel}</Label>
|
||||
<Input
|
||||
id={`${entryId}-key`}
|
||||
defaultValue={key}
|
||||
placeholder={streamNamePlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onBlur={(e) => handleRenameKey(key, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-10 space-y-2 md:col-span-6">
|
||||
<Label htmlFor={`${entryId}-value`}>{go2rtcStreamLabel}</Label>
|
||||
<StreamValueCombobox
|
||||
id={`${entryId}-value`}
|
||||
value={value}
|
||||
options={go2rtcStreamNames}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
onChange={(next) => handleUpdateValue(key, next)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end md:col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveEntry(key)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
className="shrink-0"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddEntry}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{addStreamLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default LiveStreamsField;
|
||||
@@ -2,3 +2,4 @@
|
||||
export { LayoutGridField } from "./LayoutGridField";
|
||||
export { DetectorHardwareField } from "./DetectorHardwareField";
|
||||
export { ReplaceRulesField } from "./ReplaceRulesField";
|
||||
export { LiveStreamsField } from "./LiveStreamsField";
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
||||
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
|
||||
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
|
||||
import { SemanticSearchModelSizeWidget } from "./widgets/SemanticSearchModelSizeWidget";
|
||||
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
|
||||
|
||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||
@@ -50,6 +51,7 @@ import { ReplaceRulesField } from "./fields/ReplaceRulesField";
|
||||
import { CameraInputsField } from "./fields/CameraInputsField";
|
||||
import { DictAsYamlField } from "./fields/DictAsYamlField";
|
||||
import { KnownPlatesField } from "./fields/KnownPlatesField";
|
||||
import { LiveStreamsField } from "./fields/LiveStreamsField";
|
||||
|
||||
export interface FrigateTheme {
|
||||
widgets: RegistryWidgetsType;
|
||||
@@ -86,6 +88,7 @@ export const frigateTheme: FrigateTheme = {
|
||||
timezoneSelect: TimezoneSelectWidget,
|
||||
optionalField: OptionalFieldWidget,
|
||||
semanticSearchModel: SemanticSearchModelWidget,
|
||||
semanticSearchModelSize: SemanticSearchModelSizeWidget,
|
||||
onvifProfile: OnvifProfileWidget,
|
||||
},
|
||||
templates: {
|
||||
@@ -107,5 +110,6 @@ export const frigateTheme: FrigateTheme = {
|
||||
CameraInputsField: CameraInputsField,
|
||||
DictAsYamlField: DictAsYamlField,
|
||||
KnownPlatesField: KnownPlatesField,
|
||||
LiveStreamsField: LiveStreamsField,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
getUiOptions,
|
||||
ADDITIONAL_PROPERTY_FLAG,
|
||||
} from "@rjsf/utils";
|
||||
import { ComponentType, ReactNode } from "react";
|
||||
import { ComponentType, ReactNode, useContext } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { FieldMessagesContext } from "../../FieldMessagesContext";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -95,6 +96,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
"views/settings",
|
||||
]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const allFieldMessages = useContext(FieldMessagesContext);
|
||||
|
||||
if (hidden) {
|
||||
return <div className="hidden">{children}</div>;
|
||||
@@ -384,21 +386,15 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
const beforeContent = renderCustom(beforeSpec);
|
||||
const afterContent = renderCustom(afterSpec);
|
||||
|
||||
// Render conditional field messages from ui:messages
|
||||
const fieldMessageSpecs = uiSchema?.["ui:messages"] as
|
||||
| Array<{
|
||||
key: string;
|
||||
messageKey: string;
|
||||
severity: string;
|
||||
position?: string;
|
||||
}>
|
||||
| undefined;
|
||||
const beforeMessages = fieldMessageSpecs?.filter(
|
||||
// Read field-level conditional messages from FieldMessagesContext
|
||||
const fieldPathStr = pathSegments.join(".");
|
||||
const fieldMessageSpecs = allFieldMessages.filter(
|
||||
(m) => m.field === fieldPathStr,
|
||||
);
|
||||
const beforeMessages = fieldMessageSpecs.filter(
|
||||
(m) => (m.position ?? "before") === "before",
|
||||
);
|
||||
const afterMessages = fieldMessageSpecs?.filter(
|
||||
(m) => m.position === "after",
|
||||
);
|
||||
const afterMessages = fieldMessageSpecs.filter((m) => m.position === "after");
|
||||
const beforeMessagesContent =
|
||||
beforeMessages && beforeMessages.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Disables model_size and shows "N/A" when a GenAI provider is selected.
|
||||
// Reads model via LiveFormDataContext so it re-runs even when RJSF's
|
||||
// SchemaField memoization would skip this widget.
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LiveFormDataContext } from "../../LiveFormDataContext";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
import { SelectWidget } from "./SelectWidget";
|
||||
|
||||
export function SemanticSearchModelSizeWidget(props: WidgetProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const liveFormData = useContext(LiveFormDataContext);
|
||||
const model = liveFormData?.model;
|
||||
const isProvider =
|
||||
typeof model === "string" &&
|
||||
model !== "" &&
|
||||
model !== "jinav1" &&
|
||||
model !== "jinav2";
|
||||
|
||||
// Clear model_size while on a provider (buildOverrides converts to ""
|
||||
// which the backend treats as "remove"). Restore the schema default
|
||||
// when returning to a Jina model so the field isn't left empty.
|
||||
const { value, onChange, schema } = props;
|
||||
const schemaDefault = schema?.default as string | undefined;
|
||||
useEffect(() => {
|
||||
if (isProvider && value !== undefined) {
|
||||
onChange(undefined);
|
||||
} else if (!isProvider && value === undefined && schemaDefault) {
|
||||
onChange(schemaDefault);
|
||||
}
|
||||
}, [isProvider, value, onChange, schemaDefault]);
|
||||
|
||||
if (isProvider) {
|
||||
const fieldClassName = getSizedFieldClassName(props.options ?? {}, "sm");
|
||||
return (
|
||||
<Select value="" disabled>
|
||||
<SelectTrigger className={fieldClassName}>
|
||||
<SelectValue
|
||||
placeholder={t("configForm.semanticSearchModelSize.notApplicable", {
|
||||
defaultValue: "Not applicable for GenAI providers",
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return <SelectWidget {...props} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -109,6 +109,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
"nb-NO": "nb",
|
||||
"yue-Hant": "yue",
|
||||
"zh-CN": "zhCN",
|
||||
"zh-Hant": "zhHant",
|
||||
"pt-BR": "ptBR",
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, ReactNode, useCallback } from "react";
|
||||
import { SearchResult } from "@/types/search";
|
||||
import { REVIEW_PADDING } from "@/types/review";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { toast } from "sonner";
|
||||
@@ -94,8 +95,8 @@ export default function SearchResultActions({
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: event.camera,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
start_time: (event.start_time ?? 0) - REVIEW_PADDING,
|
||||
end_time: (event.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
@@ -129,9 +130,15 @@ export default function SearchResultActions({
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
ns: "views/replay",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -205,7 +212,7 @@ export default function SearchResultActions({
|
||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{searchResult.has_clip && (
|
||||
{isAdmin && searchResult.has_clip && (
|
||||
<MenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("itemMenu.debugReplay.aria")}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { FaFilm } from "react-icons/fa6";
|
||||
|
||||
type ActionsDropdownProps = {
|
||||
onDebugReplayClick: () => void;
|
||||
onDebugReplayClick?: () => void;
|
||||
onExportClick: () => void;
|
||||
onShareTimestampClick: () => void;
|
||||
};
|
||||
@@ -42,9 +42,11 @@ export default function ActionsDropdown({
|
||||
<DropdownMenuItem onClick={onShareTimestampClick}>
|
||||
{t("recording.shareTimestamp.label", { ns: "components/dialog" })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDebugReplayClick}>
|
||||
{t("title", { ns: "views/replay" })}
|
||||
</DropdownMenuItem>
|
||||
{onDebugReplayClick && (
|
||||
<DropdownMenuItem onClick={onDebugReplayClick}>
|
||||
{t("title", { ns: "views/replay" })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuSettings } from "react-icons/lu";
|
||||
import useSWR from "swr";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlatformAwareSheet } from "@/components/overlay/dialog/PlatformAwareDialog";
|
||||
import { useConfigSchema } from "@/hooks/use-config-schema";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
|
||||
type DebugReplayConfigSheetProps = {
|
||||
replayCamera: string | undefined;
|
||||
};
|
||||
|
||||
export function DebugReplayConfigSheet({
|
||||
replayCamera,
|
||||
}: DebugReplayConfigSheetProps) {
|
||||
const { t } = useTranslation(["views/replay"]);
|
||||
const configSchema = useConfigSchema();
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<PlatformAwareSheet
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-2">
|
||||
<LuSettings className="size-4" />
|
||||
<span className="hidden md:inline">{t("page.configuration")}</span>
|
||||
</Button>
|
||||
}
|
||||
title={t("page.configuration")}
|
||||
titleClassName="text-lg font-semibold"
|
||||
contentClassName="scrollbar-container flex flex-col gap-0 overflow-y-auto px-6 pb-6 sm:max-w-xl md:max-w-2xl xl:max-w-3xl"
|
||||
content={
|
||||
<>
|
||||
<p className="mb-5 text-sm text-muted-foreground">
|
||||
{t("page.configurationDesc")}
|
||||
</p>
|
||||
{configSchema == null ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="detect"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="motion"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="objects"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
{config?.face_recognition?.enabled && (
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="face_recognition"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
)}
|
||||
{config?.lpr?.enabled && (
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="lpr"
|
||||
level="replay"
|
||||
cameraName={replayCamera}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { StartExportResponse } from "@/types/export";
|
||||
import { ShareTimestampContent } from "./ShareTimestampDialog";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
|
||||
type DrawerMode =
|
||||
| "none"
|
||||
@@ -109,6 +110,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
"views/replay",
|
||||
"common",
|
||||
]);
|
||||
const isAdmin = useIsAdmin();
|
||||
const navigate = useNavigate();
|
||||
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
|
||||
const [exportTab, setExportTab] = useState<ExportTab>("export");
|
||||
@@ -388,7 +390,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
{t("filter")}
|
||||
</Button>
|
||||
)}
|
||||
{features.includes("debug-replay") && (
|
||||
{isAdmin && features.includes("debug-replay") && (
|
||||
<Button
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label={t("title", { ns: "views/replay" })}
|
||||
|
||||
@@ -63,8 +63,8 @@ export default function DetailActionsMenu({
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: search.camera,
|
||||
start_time: search.start_time,
|
||||
end_time: search.end_time,
|
||||
start_time: (search.start_time ?? 0) - REVIEW_PADDING,
|
||||
end_time: (search.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
@@ -95,9 +95,15 @@ export default function DetailActionsMenu({
|
||||
),
|
||||
});
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
ns: "views/replay",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -229,7 +235,7 @@ export default function DetailActionsMenu({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{search.has_clip && (
|
||||
{isAdmin && search.has_clip && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("itemMenu.debugReplay.aria")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Event } from "@/types/event";
|
||||
import { REVIEW_PADDING } from "@/types/review";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
@@ -58,8 +59,8 @@ export default function EventMenu({
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: event.camera,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
start_time: (event.start_time ?? 0) - REVIEW_PADDING,
|
||||
end_time: (event.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
@@ -93,9 +94,15 @@ export default function EventMenu({
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
ns: "views/replay",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -176,7 +183,7 @@ export default function EventMenu({
|
||||
{t("itemMenu.findSimilar.label")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{event.has_clip && (
|
||||
{isAdmin && event.has_clip && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
disabled={isStarting}
|
||||
|
||||
@@ -34,6 +34,8 @@ const localeMap: Record<string, () => Promise<Locale>> = {
|
||||
sk: () => import("date-fns/locale/sk").then((module) => module.sk),
|
||||
"yue-Hant": () =>
|
||||
import("date-fns/locale/zh-HK").then((module) => module.zhHK),
|
||||
"zh-Hant": () =>
|
||||
import("date-fns/locale/zh-TW").then((module) => module.zhTW),
|
||||
lt: () => import("date-fns/locale/lt").then((module) => module.lt),
|
||||
th: () => import("date-fns/locale/th").then((module) => module.th),
|
||||
ca: () => import("date-fns/locale/ca").then((module) => module.ca),
|
||||
|
||||
@@ -29,6 +29,7 @@ export const supportedLanguageKeys = [
|
||||
"nb-NO",
|
||||
"sv",
|
||||
"zh-CN",
|
||||
"zh-Hant",
|
||||
"yue-Hant",
|
||||
"ja",
|
||||
"vi",
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { PlatformAwareSheet } from "@/components/overlay/dialog/PlatformAwareDialog";
|
||||
import { DebugReplayConfigSheet } from "@/components/overlay/DebugReplayConfigSheet";
|
||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Heading from "@/components/ui/heading";
|
||||
@@ -40,16 +40,14 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { ObjectType } from "@/types/ws";
|
||||
import { useJobStatus } from "@/api/ws";
|
||||
import WsMessageFeed from "@/components/ws/WsMessageFeed";
|
||||
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
|
||||
|
||||
import { LuExternalLink, LuInfo, LuSettings } from "react-icons/lu";
|
||||
import { LuExternalLink, LuInfo } from "react-icons/lu";
|
||||
import { LuSquare } from "react-icons/lu";
|
||||
import { MdReplay } from "react-icons/md";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import Logo from "@/components/Logo";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { useConfigSchema } from "@/hooks/use-config-schema";
|
||||
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
|
||||
@@ -125,7 +123,6 @@ export default function Replay() {
|
||||
});
|
||||
const { payload: replayJob } =
|
||||
useJobStatus<DebugReplayJobResults>("debug_replay");
|
||||
const configSchema = useConfigSchema();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
|
||||
// Refresh status immediately on mount to avoid showing "no session" briefly
|
||||
@@ -139,7 +136,6 @@ export default function Replay() {
|
||||
|
||||
const [options, setOptions] = useState<DebugOptions>(DEFAULT_OPTIONS);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||
|
||||
const searchParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -327,91 +323,8 @@ export default function Replay() {
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<PlatformAwareSheet
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LuSettings className="size-4" />
|
||||
<span className="hidden md:inline">
|
||||
{t("page.configuration")}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
title={t("page.configuration")}
|
||||
titleClassName="text-lg font-semibold"
|
||||
contentClassName="scrollbar-container flex flex-col gap-0 overflow-y-auto px-6 pb-6 sm:max-w-xl md:max-w-2xl xl:max-w-3xl"
|
||||
content={
|
||||
<>
|
||||
<p className="mb-5 text-sm text-muted-foreground">
|
||||
{t("page.configurationDesc")}
|
||||
</p>
|
||||
{configSchema == null ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="motion"
|
||||
level="replay"
|
||||
cameraName={status.replay_camera ?? undefined}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="objects"
|
||||
level="replay"
|
||||
cameraName={status.replay_camera ?? undefined}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
{config?.face_recognition?.enabled && (
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="face_recognition"
|
||||
level="replay"
|
||||
cameraName={status.replay_camera ?? undefined}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
)}
|
||||
{config?.lpr?.enabled && (
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="lpr"
|
||||
level="replay"
|
||||
cameraName={status.replay_camera ?? undefined}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
open={configDialogOpen}
|
||||
onOpenChange={setConfigDialogOpen}
|
||||
<DebugReplayConfigSheet
|
||||
replayCamera={status.replay_camera ?? undefined}
|
||||
/>
|
||||
|
||||
<AlertDialog>
|
||||
|
||||
+181
-69
@@ -16,6 +16,11 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -305,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 },
|
||||
@@ -326,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 },
|
||||
@@ -589,7 +594,7 @@ function MobileMenuItem({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-10 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md px-4 py-2 pr-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex h-10 w-full cursor-pointer items-center whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -600,7 +605,6 @@ function MobileMenuItem({
|
||||
<div className="w-full">
|
||||
{label ?? <div>{t("menu." + item.key)}</div>}
|
||||
</div>
|
||||
<LuChevronRight className="size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -613,6 +617,39 @@ export default function Settings() {
|
||||
const [sectionStatusByKey, setSectionStatusByKey] = useState<
|
||||
Partial<Record<SettingsType, SectionStatus>>
|
||||
>({});
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
|
||||
() =>
|
||||
// all collapsed by default
|
||||
new Set(
|
||||
settingsGroups.filter((g) => g.items.length > 1).map((g) => g.label),
|
||||
),
|
||||
);
|
||||
|
||||
const toggleGroupCollapsed = useCallback((label: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) {
|
||||
next.delete(label);
|
||||
} else {
|
||||
next.add(label);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-expand the group containing the active page whenever pageToggle changes
|
||||
useEffect(() => {
|
||||
const containingGroup = settingsGroups.find((group) =>
|
||||
group.items.some((item) => item.key === pageToggle),
|
||||
);
|
||||
if (!containingGroup) return;
|
||||
setCollapsedGroups((prev) => {
|
||||
if (!prev.has(containingGroup.label)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(containingGroup.label);
|
||||
return next;
|
||||
});
|
||||
}, [pageToggle]);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
@@ -1611,34 +1648,58 @@ export default function Settings() {
|
||||
visibleSettingsViews.includes(item.key as SettingsType),
|
||||
);
|
||||
if (filteredItems.length === 0) return null;
|
||||
const isMultiItem = filteredItems.length > 1;
|
||||
const renderedExpanded =
|
||||
!isMultiItem || !collapsedGroups.has(group.label);
|
||||
const items = filteredItems.map((item) => (
|
||||
<MobileMenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
className={cn(filteredItems.length == 1 && "pl-2")}
|
||||
label={renderMenuItemLabel(item.key as SettingsType)}
|
||||
onSelect={(key) => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!ALLOWED_VIEWS_FOR_VIEWER.includes(key as SettingsType)
|
||||
) {
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(key as SettingsType);
|
||||
}
|
||||
setContentMobileOpen(true);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
return (
|
||||
<div key={group.label} className="mb-3">
|
||||
{filteredItems.length > 1 && (
|
||||
<h3 className="mb-2 ml-2 text-sm font-medium text-secondary-foreground">
|
||||
<div>{t("menu." + group.label)}</div>
|
||||
</h3>
|
||||
{isMultiItem ? (
|
||||
<Collapsible
|
||||
open={renderedExpanded}
|
||||
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 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",
|
||||
renderedExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>{items}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
items
|
||||
)}
|
||||
{filteredItems.map((item) => (
|
||||
<MobileMenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
className={cn(filteredItems.length == 1 && "pl-2")}
|
||||
label={renderMenuItemLabel(item.key as SettingsType)}
|
||||
onSelect={(key) => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
||||
key as SettingsType,
|
||||
)
|
||||
) {
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(key as SettingsType);
|
||||
}
|
||||
setContentMobileOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1940,48 +2001,99 @@ export default function Settings() {
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
) : (
|
||||
<>
|
||||
<SidebarGroupLabel
|
||||
className={cn(
|
||||
"ml-2 cursor-default pl-0 text-sm",
|
||||
filteredItems.some(
|
||||
(item) => pageToggle === item.key,
|
||||
)
|
||||
? "text-primary"
|
||||
: "text-sidebar-foreground/80",
|
||||
)}
|
||||
>
|
||||
<div>{t("menu." + group.label)}</div>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarMenuSub className="mx-2 border-0">
|
||||
{filteredItems.map((item) => (
|
||||
<SidebarMenuSubItem key={item.key}>
|
||||
<SidebarMenuSubButton
|
||||
className="h-auto w-full py-1.5"
|
||||
isActive={pageToggle === item.key}
|
||||
onClick={() => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
||||
item.key as SettingsType,
|
||||
)
|
||||
) {
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(item.key as SettingsType);
|
||||
}
|
||||
}}
|
||||
(() => {
|
||||
const hasActiveItem = filteredItems.some(
|
||||
(item) => pageToggle === item.key,
|
||||
);
|
||||
const renderedExpanded = !collapsedGroups.has(
|
||||
group.label,
|
||||
);
|
||||
return (
|
||||
<Collapsible
|
||||
open={renderedExpanded}
|
||||
onOpenChange={() =>
|
||||
toggleGroupCollapsed(group.label)
|
||||
}
|
||||
>
|
||||
<SidebarGroupLabel
|
||||
asChild
|
||||
className={cn(
|
||||
"ml-2 pl-0 text-sm",
|
||||
hasActiveItem
|
||||
? "text-primary"
|
||||
: "text-sidebar-foreground/80",
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between",
|
||||
renderedExpanded &&
|
||||
group.label == "cameras" &&
|
||||
"mb-2",
|
||||
)}
|
||||
>
|
||||
<div className="w-full cursor-pointer">
|
||||
{renderMenuItemLabel(
|
||||
item.key as SettingsType,
|
||||
)}
|
||||
<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>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</>
|
||||
<LuChevronRight
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-transform duration-200",
|
||||
renderedExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub className="mx-2 border-0 md:mx-0">
|
||||
{filteredItems.map((item) => (
|
||||
<SidebarMenuSubItem key={item.key}>
|
||||
<SidebarMenuSubButton
|
||||
className="h-auto w-full py-1.5"
|
||||
isActive={pageToggle === item.key}
|
||||
onClick={() => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
||||
item.key as SettingsType,
|
||||
)
|
||||
) {
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(
|
||||
item.key as SettingsType,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full cursor-pointer">
|
||||
{renderMenuItemLabel(
|
||||
item.key as SettingsType,
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</SidebarGroup>
|
||||
);
|
||||
|
||||
@@ -229,7 +229,12 @@ export function buildOverrides(
|
||||
|
||||
const result: JsonObject = {};
|
||||
for (const [key, value] of Object.entries(currentObj)) {
|
||||
if (value === undefined && baseObj && baseObj[key] !== undefined) {
|
||||
if (
|
||||
(value === undefined || value === null) &&
|
||||
baseObj &&
|
||||
baseObj[key] !== undefined &&
|
||||
baseObj[key] !== null
|
||||
) {
|
||||
result[key] = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
import VideoControls from "@/components/player/VideoControls";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import {
|
||||
useCameraMotionNextTimestamp,
|
||||
useCameraMotionOnlyRanges,
|
||||
@@ -1008,27 +1009,29 @@ function MotionReview({
|
||||
const { t } = useTranslation(["views/events", "common"]);
|
||||
const segmentDuration = 30;
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const allowedCameras = useAllowedCameras();
|
||||
|
||||
const reviewCameras = useMemo(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let cameras;
|
||||
if (!filter || !filter.cameras) {
|
||||
cameras = Object.values(config.cameras).filter(
|
||||
(cam) => !isReplayCamera(cam.name),
|
||||
);
|
||||
} else {
|
||||
const filteredCams = filter.cameras;
|
||||
|
||||
cameras = Object.values(config.cameras).filter(
|
||||
(cam) => filteredCams.includes(cam.name) && !isReplayCamera(cam.name),
|
||||
);
|
||||
}
|
||||
const selectedCams = filter?.cameras;
|
||||
const cameras = Object.values(config.cameras).filter((cam) => {
|
||||
if (isReplayCamera(cam.name)) {
|
||||
return false;
|
||||
}
|
||||
if (!allowedCameras.includes(cam.name)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedCams && !selectedCams.includes(cam.name)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return cameras.sort((a, b) => a.ui.order - b.ui.order);
|
||||
}, [config, filter]);
|
||||
}, [config, filter, allowedCameras]);
|
||||
|
||||
const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({});
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
@@ -90,6 +91,7 @@ export default function LiveDashboardView({
|
||||
// recent events
|
||||
|
||||
const eventUpdate = useFrigateReviews();
|
||||
const allowedCameras = useAllowedCameras();
|
||||
|
||||
const alertCameras = useMemo(() => {
|
||||
if (!config) {
|
||||
@@ -98,14 +100,16 @@ export default function LiveDashboardView({
|
||||
|
||||
if (cameraGroup == "default") {
|
||||
return Object.values(config.cameras)
|
||||
.filter((cam) => cam.ui.dashboard)
|
||||
.filter((cam) => cam.ui.dashboard && allowedCameras.includes(cam.name))
|
||||
.map((cam) => cam.name)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
if (includeBirdseye && cameras.length == 0) {
|
||||
return Object.values(config.cameras)
|
||||
.filter((cam) => cam.birdseye.enabled)
|
||||
.filter(
|
||||
(cam) => cam.birdseye.enabled && allowedCameras.includes(cam.name),
|
||||
)
|
||||
.map((cam) => cam.name)
|
||||
.join(",");
|
||||
}
|
||||
@@ -114,7 +118,7 @@ export default function LiveDashboardView({
|
||||
.map((cam) => cam.name)
|
||||
.filter((cam) => config.camera_groups[cameraGroup]?.cameras.includes(cam))
|
||||
.join(",");
|
||||
}, [cameras, cameraGroup, config, includeBirdseye]);
|
||||
}, [cameras, cameraGroup, config, includeBirdseye, allowedCameras]);
|
||||
|
||||
const { data: allEvents, mutate: updateEvents } = useSWR<ReviewSegment[]>([
|
||||
"review",
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import useSWR from "swr";
|
||||
import { TimeRange, TimelineType } from "@/types/timeline";
|
||||
import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
|
||||
@@ -109,6 +110,7 @@ export function RecordingView({
|
||||
}: RecordingViewProps) {
|
||||
const { t } = useTranslation(["views/events", "components/dialog"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const isAdmin = useIsAdmin();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -723,13 +725,17 @@ export function RecordingView({
|
||||
setCustomShareTimestamp(initialTimestamp);
|
||||
setShareTimestampOpen(true);
|
||||
}}
|
||||
onDebugReplayClick={() => {
|
||||
setDebugReplayRange({
|
||||
after: timeRange.before - 60,
|
||||
before: timeRange.before,
|
||||
});
|
||||
setDebugReplayMode("select");
|
||||
}}
|
||||
onDebugReplayClick={
|
||||
isAdmin
|
||||
? () => {
|
||||
setDebugReplayRange({
|
||||
after: timeRange.before - 60,
|
||||
before: timeRange.before,
|
||||
});
|
||||
setDebugReplayMode("select");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onExportClick={() => {
|
||||
const now = new Date(timeRange.before * 1000);
|
||||
now.setHours(now.getHours() - 1);
|
||||
|
||||
@@ -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";
|
||||
@@ -36,7 +33,15 @@ import axios from "axios";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -53,18 +58,27 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
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"]);
|
||||
@@ -72,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);
|
||||
|
||||
@@ -207,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
|
||||
@@ -225,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>
|
||||
|
||||
@@ -449,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();
|
||||
|
||||
@@ -482,62 +434,316 @@ function EnabledCameraRow({
|
||||
<LuGripVertical className="size-4" />
|
||||
</button>
|
||||
<CameraNameLabel camera={camera} />
|
||||
<CameraFriendlyNameEditor
|
||||
<CameraDetailsEditor
|
||||
cameraName={camera}
|
||||
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 CameraFriendlyNameEditorProps = {
|
||||
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>;
|
||||
};
|
||||
|
||||
function CameraFriendlyNameEditor({
|
||||
type CameraDetailsFormValues = {
|
||||
friendlyName: string;
|
||||
webuiUrl: string;
|
||||
};
|
||||
|
||||
function CameraDetailsEditor({
|
||||
cameraName,
|
||||
onConfigChanged,
|
||||
}: CameraFriendlyNameEditorProps) {
|
||||
}: CameraDetailsEditorProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name;
|
||||
const currentWebuiUrl = config?.cameras?.[cameraName]?.webui_url;
|
||||
|
||||
const onSave = useCallback(
|
||||
async (text: string) => {
|
||||
const formSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
friendlyName: z.string(),
|
||||
webuiUrl: z.string().refine(
|
||||
(val) => {
|
||||
const trimmed = val.trim();
|
||||
if (!trimmed) return true;
|
||||
try {
|
||||
new URL(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: t("cameraManagement.streams.details.webuiUrlInvalid", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
},
|
||||
),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const form = useForm<CameraDetailsFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
friendlyName: currentFriendlyName ?? "",
|
||||
webuiUrl: currentWebuiUrl ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form values from config whenever the dialog is opened.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({
|
||||
friendlyName: currentFriendlyName ?? "",
|
||||
webuiUrl: currentWebuiUrl ?? "",
|
||||
});
|
||||
}
|
||||
}, [open, currentFriendlyName, currentWebuiUrl, form]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (values: CameraDetailsFormValues) => {
|
||||
if (isSaving) return;
|
||||
|
||||
// only send fields the user actually changed
|
||||
const newFriendly = values.friendlyName.trim() || null;
|
||||
const newWebui = values.webuiUrl.trim() || null;
|
||||
const cameraUpdate: Record<string, string | null> = {};
|
||||
if (newFriendly !== (currentFriendlyName ?? null)) {
|
||||
cameraUpdate.friendly_name = newFriendly;
|
||||
}
|
||||
if (newWebui !== (currentWebuiUrl ?? null)) {
|
||||
cameraUpdate.webui_url = newWebui;
|
||||
}
|
||||
|
||||
if (Object.keys(cameraUpdate).length === 0) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
@@ -545,9 +751,7 @@ function CameraFriendlyNameEditor({
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
cameras: {
|
||||
[cameraName]: {
|
||||
friendly_name: text.trim() || null,
|
||||
},
|
||||
[cameraName]: cameraUpdate,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -573,10 +777,17 @@ function CameraFriendlyNameEditor({
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[cameraName, isSaving, onConfigChanged, t],
|
||||
[
|
||||
cameraName,
|
||||
currentFriendlyName,
|
||||
currentWebuiUrl,
|
||||
isSaving,
|
||||
onConfigChanged,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const renameLabel = t("cameraManagement.streams.friendlyName.rename", {
|
||||
const editLabel = t("cameraManagement.streams.details.edit", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
|
||||
@@ -588,122 +799,108 @@ function CameraFriendlyNameEditor({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={renameLabel}
|
||||
aria-label={editLabel}
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<LuPencil className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{renameLabel}</TooltipContent>
|
||||
<TooltipContent>{editLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
<TextEntryDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={t("cameraManagement.streams.friendlyName.title", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
description={t("cameraManagement.streams.friendlyName.description", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
defaultValue={currentFriendlyName ?? ""}
|
||||
placeholder={currentFriendlyName ? undefined : cameraName}
|
||||
allowEmpty
|
||||
isSaving={isSaving}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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" })}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("cameraManagement.streams.details.title", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("cameraManagement.streams.details.description", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="friendlyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("cameraManagement.streams.details.friendlyNameLabel", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={cameraName}
|
||||
disabled={isSaving}
|
||||
autoFocus
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("cameraManagement.streams.details.friendlyNameHelp", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webuiUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("cameraManagement.streams.details.webuiUrlLabel", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("cameraManagement.streams.details.webuiUrlHelp", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</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>
|
||||
<Button variant="select" type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1064,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>
|
||||
|
||||
Reference in New Issue
Block a user