Compare commits

...
10 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub 168cbea9ea Docs tweaks (#23787)
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
* docs fixes

* backend tweaks

* regenerate i18n

* tweak genai

* add config overrides section

* add common errors

* add suggestions for rebuilding a corrupt database
2026-07-22 11:13:58 -05:00
Josh HawkinsandGitHub c0cf08ab4a Miscellaneous fixes (0.18 beta) (#23763)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-07-21 06:44:33 -06:00
Josh HawkinsandGitHub 6f80bcd19f Miscellaneous fixes (0.18 beta) (#23755)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* resolve saved credential sentinel to the stored api_key in the GenAI probe

* add profile faq

* center the multi-camera export time range on the current playback position

* add faq about preview restart cache

* clarify exports bulk download
2026-07-18 11:19:37 -06:00
d02a1156b7 Miscellaneous fixes (0.18 beta) (#23736)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* Catch faces that become empty after cropping

* don't drop batched camera add/remove config updates

TrackedObjectProcessor drained all pending camera config updates at once but handled them in a mutually exclusive if/elif on enabled/add/remove, so only one topic was processed per drain. When an add arrived in the same batch as an enabled update, the add was skipped and the new camera never got a camera state. Adding a camera reliably produced that batch: config_set now re-applies runtime overrides, which republishes an enabled update for every previously toggled camera immediately before the add, in the same request. The dashboard and camera capture still saw the camera (the maintainer does not subscribe to enabled, so it got a clean add-only batch), but object_processing did not, and disabling the camera then crashed with a KeyError on the unguarded camera_states lookup.

Handle add and remove independently instead of as exclusive branches so a batched add is no longer dropped, and guard the remove lookup so a missing state is skipped rather than raising. Drop the enabled branch entirely: it only ever set prev_enabled when it was None, but prev_enabled is seeded to a bool at camera state creation and is never None (mypy flags the body as unreachable), and the actual enable/disable transition is already driven by the disabled-state loop from config.enabled.

* Don't stay on motion search page when user cancels flow

* fix notification test button being blocked by websocket auth

* fix overflowing model names in settings genai widget

* add note about auth debugging

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-07-17 08:00:15 -06:00
GuoQing LiuandGitHub c17538aff9 Frontend Miscellaneous fixes (#23751)
* fix: fix logger page i18n

* fix: fix button components text

* fix: fix command components scrollbar

* revert: revert fix button components text
2026-07-17 06:30:02 -06:00
Martin WeineltandGitHub dd7e9f1bc5 Fix invalid escape sequences in RTSP password test (#23740)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-07-16 14:58:26 -06:00
Josh HawkinsandGitHub 48aaafba3c re-apply runtime overrides to the config without re-broadcasting them (#23739)
/api/config/set and camera deletion re-parse yaml into a fresh FrigateConfig and swap it in, then re-layered the persisted runtime toggle overrides so a camera the user turned off wouldn't come back on. That re-layer ran apply_runtime_state, which replays each override through the command handlers, so every save re-published a ZMQ config update, a retained MQTT state message, and a runtime-state disk write for every camera with a stored toggle. All of it was redundant: the worker processes were never swapped and still hold the live toggle values, so only the in-process config object the API and dispatcher read was out of date. The extra traffic churned the retained MQTT topics, amplified disk writes, and co-drained enabled updates with other topics on the config socket.

Add Dispatcher.reapply_runtime_state_to_config, which corrects only the swapped-in config object, mirroring the field mutations and gates of the _on_*_command handlers with no ZMQ, MQTT, or disk writes. swap_runtime_config now calls it instead of apply_runtime_state; apply_runtime_state is unchanged and still used at startup, where the workers genuinely must be told.
2026-07-16 13:30:41 -05:00
Josh HawkinsandGitHub f1028d0c36 Fix persisted runtime camera toggles (#23734)
* preserve runtime camera toggles across config saves

Runtime toggles (camera on/off, detect, recordings, snapshots, audio) mutate the in-memory config and persist an override to .runtime_state.json. /api/config/set re-parses yaml into a fresh FrigateConfig and swaps it in, re-applying the yaml and profile layers but dropping the runtime layer, so a camera turned off from the dashboard came back on when an unrelated camera was saved. The workers were never notified, so it only appeared to come back: the UI streamed go2rtc while ffmpeg stayed stopped.

Extract the startup replay into Dispatcher.apply_runtime_state() and call it from config_set after the swap, re-layering the overrides and republishing them so workers and the UI reconverge.

Remove the broad clear_runtime_state() from ProfileManager.update_config, which is only ever reached from config_set: with a profile active, every save wiped every camera's overrides from disk. The broad wipe stays in activate_profile, where a real profile switch does invalidate the steady state. Saves still clear the keys they rewrote via clear_runtime_state_for_yaml_keys, so yaml wins where the two disagree.

* sync runtime config on camera delete and prune its overrides

Deleting a camera re-parsed yaml into a fresh FrigateConfig but only rebound app.frigate_config and genai_manager, never dispatcher.config (nor profile_manager, stats_emitter, or the runtime overrides). The API and the dispatcher then drifted onto different config objects until the next config save re-synced them, so the API reported surviving cameras with their yaml enabled state while the dispatcher still acted on their real runtime state.

Extract the config swap that config_set already does into a shared swap_runtime_config helper and call it from both sites, so every collaborator is rebound and the surviving cameras' runtime toggles are re-layered. Also drop the deleted camera's persisted overrides via a new clear_camera so a camera later added under the same name does not inherit them.
2026-07-16 09:05:21 -05:00
Nicolas MowenandGitHub 70d629bf93 Update OpenVINO model generation (#23733)
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
2026-07-16 08:00:52 -06:00
Josh HawkinsandGitHub c406a93d3d Miscellaneous fixes (0.18 beta) (#23725)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-07-15 18:49:05 -06:00
82 changed files with 2656 additions and 354 deletions
+1
View File
@@ -12,6 +12,7 @@ config/*
models models
*.mp4 *.mp4
*.db *.db
*.db-*
*.csv *.csv
frigate/version.py frigate/version.py
web/build web/build
+2 -2
View File
@@ -81,10 +81,10 @@ RUN --mount=type=bind,source=docker/main/install_tempio.sh,target=/deps/install_
FROM base_host AS ov-converter FROM base_host AS ov-converter
ARG DEBIAN_FRONTEND ARG DEBIAN_FRONTEND
# Install OpenVino Runtime and Dev library # Install OpenVINO for model conversion
COPY docker/main/requirements-ov.txt /requirements-ov.txt COPY docker/main/requirements-ov.txt /requirements-ov.txt
RUN apt-get -qq update \ RUN apt-get -qq update \
&& apt-get -qq install -y wget python3 python3-dev python3-distutils gcc pkg-config libhdf5-dev \ && apt-get -qq install -y wget python3 python3-distutils \
&& wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ && wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \ && sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
&& python3 get-pip.py "pip" \ && python3 get-pip.py "pip" \
+39 -8
View File
@@ -1,11 +1,42 @@
import openvino as ov """Convert the default SSDLite MobileNet v2 model to OpenVINO IR.
from openvino.tools import mo
ov_model = mo.convert_model( Replaces the legacy openvino-dev Model Optimizer conversion. The TensorFlow
frontend converts the Object Detection API frozen graph natively; the four TF
outputs are then repacked into the single [1, 1, 100, 7] DetectionOutput-style
tensor that Frigate's OpenVINO detector expects, and the input is flipped to
BGR to match the legacy reverse_input_channels behavior.
"""
import numpy as np
import openvino as ov
from openvino import opset8 as ops
from openvino.preprocess import PrePostProcessor
model = ov.convert_model(
"/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb", "/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb",
compress_to_fp16=True, input=[("image_tensor:0", [1, 300, 300, 3])],
transformations_config="/usr/local/lib/python3.11/dist-packages/openvino/tools/mo/front/tf/ssd_v2_support.json",
tensorflow_object_detection_api_pipeline_config="/models/ssdlite_mobilenet_v2_coco_2018_05_09/pipeline.config",
reverse_input_channels=True,
) )
ov.save_model(ov_model, "/models/ssdlite_mobilenet_v2.xml")
# rows of (image_id, class_id, score, xmin, ymin, xmax, ymax)
boxes = model.output("detection_boxes:0").get_node().input_value(0)
classes = model.output("detection_classes:0").get_node().input_value(0)
scores = model.output("detection_scores:0").get_node().input_value(0)
# (ymin,xmin,ymax,xmax) -> (xmin,ymin,xmax,ymax)
boxes = ops.gather(boxes, [1, 0, 3, 2], 2)
classes = ops.unsqueeze(classes, 2)
scores = ops.unsqueeze(scores, 2)
image_id = ops.multiply(scores, np.float32(0.0))
detections = ops.concat([image_id, classes, scores, boxes], 2)
detections = ops.unsqueeze(detections, 1)
detections.output(0).get_tensor().set_names({"detection_out"})
model = ov.Model([detections], model.get_parameters(), "ssdlite_mobilenet_v2")
ppp = PrePostProcessor(model)
ppp.input().tensor().set_layout(ov.Layout("NHWC"))
ppp.input().preprocess().reverse_channels()
model = ppp.build()
ov.save_model(model, "/models/ssdlite_mobilenet_v2.xml", compress_to_fp16=True)
+1 -1
View File
@@ -2,7 +2,7 @@
set -euxo pipefail set -euxo pipefail
SQLITE_VEC_VERSION="0.1.3" SQLITE_VEC_VERSION="0.1.9"
source /etc/os-release source /etc/os-release
+1 -2
View File
@@ -1,3 +1,2 @@
numpy numpy
tensorflow openvino >= 2026.2.0
openvino-dev>=2024.0.0
+10 -6
View File
@@ -11,6 +11,8 @@ It is not recommended to copy this full configuration file. Only specify values
::: :::
Sections marked `# NOTE: Can be overridden at the camera level` can be set globally and then adjusted per camera. See [Global and Camera-Level Configuration](../config_overrides.md) for how that works.
```yaml ```yaml
mqtt: mqtt:
# Optional: Enable mqtt server (default: shown below) # Optional: Enable mqtt server (default: shown below)
@@ -171,13 +173,14 @@ model:
# Valid values are rgb, bgr, or yuv. (default: shown below) # Valid values are rgb, bgr, or yuv. (default: shown below)
input_pixel_format: rgb input_pixel_format: rgb
# Required: Object detection model input tensor format # Required: Object detection model input tensor format
# Valid values are nhwc or nchw (default: shown below) # Valid values are nhwc, nchw, hwnc, or hwcn (default: shown below)
input_tensor: nhwc input_tensor: nhwc
# Optional: Data type of the model input tensor # Optional: Data type of the model input tensor
# Valid values are float, float_denorm, or int (default: shown below) # Valid values are float, float_denorm, or int (default: shown below)
input_dtype: int input_dtype: int
# Required: Object detection model type, currently only used with the OpenVINO detector # Required: Object detection model architecture, used by detectors that support more
# Valid values are ssd, yolox, yolonas (default: shown below) # than one model type (openvino, onnx, rknn, memryx, axengine, synaptics, and others)
# Valid values are ssd, yolox, yolonas, yolo-generic, rfdetr, dfine (default: shown below)
model_type: ssd model_type: ssd
# Required: Label name modifications. These are merged into the standard labelmap. # Required: Label name modifications. These are merged into the standard labelmap.
labelmap: labelmap:
@@ -468,8 +471,8 @@ review:
detections: False detections: False
# Optional: Activity Context Prompt to give context to the GenAI what activity is and is not suspicious. # Optional: Activity Context Prompt to give context to the GenAI what activity is and is not suspicious.
# It is important to be direct and detailed. See documentation for the default prompt structure. # It is important to be direct and detailed. See documentation for the default prompt structure.
activity_context_prompt: """Define what is and is not suspicious activity_context_prompt: |
""" Define what is and is not suspicious
# Optional: Image source for GenAI (default: preview) # Optional: Image source for GenAI (default: preview)
# Options: "preview" (uses cached preview frames at ~180p) or "recordings" (extracts frames from recordings at 480p) # Options: "preview" (uses cached preview frames at ~180p) or "recordings" (extracts frames from recordings at 480p)
# Using "recordings" provides better image quality but uses more tokens per image. # Using "recordings" provides better image quality but uses more tokens per image.
@@ -813,7 +816,8 @@ classification:
cameras: cameras:
camera_name: camera_name:
# Required: Crop of image frame on this camera to run classification on # Required: Crop of image frame on this camera to run classification on
crop: [0, 180, 220, 400] # [x1, y1, x2, y2] as decimals between 0 and 1, relative to the detect resolution
crop: [0.0, 0.25, 0.3, 0.85]
# Optional: If classification should be run when motion is detected in the crop (default: shown below) # Optional: If classification should be run when motion is detected in the crop (default: shown below)
motion: False motion: False
# Optional: Interval to run classification on in seconds (default: shown below) # Optional: Interval to run classification on in seconds (default: shown below)
+1 -1
View File
@@ -335,7 +335,7 @@ For example:
``` ```
services: services:
frigate: frigate:
image: blakeblackshear/frigate:latest image: ghcr.io/blakeblackshear/frigate:stable
environment: environment:
- FRIGATE_BASE_PATH=/frigate - FRIGATE_BASE_PATH=/frigate
``` ```
+2 -2
View File
@@ -272,7 +272,7 @@ If you have CUDA hardware, you can experiment with the `large` `whisper` model o
#### Transcription and translation of `speech` audio events #### Transcription and translation of `speech` audio events
Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button in the Tracked Object Details pane. Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button (the microphone icon) in the Tracked Object Details pane.
In order to use transcription and translation for past events, you must enable audio detection and define `speech` as an audio type to listen for. To have `speech` events translated into the language of your choice, set the `language` config parameter with the correct [language code](https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10). In order to use transcription and translation for past events, you must enable audio detection and define `speech` as an audio type to listen for. To have `speech` events translated into the language of your choice, set the `language` config parameter with the correct [language code](https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10).
@@ -294,7 +294,7 @@ Recorded `speech` events will always use a `whisper` model, regardless of the `m
Because transcription is **serialized (one event at a time)** and speech events can be generated far faster than they can be processed, an auto-transcribe toggle would very quickly create an ever-growing backlog and degrade core functionality. For the amount of engineering and risk involved, it adds **very little practical value** for the majority of deployments, which are often on low-powered, edge hardware. Because transcription is **serialized (one event at a time)** and speech events can be generated far faster than they can be processed, an auto-transcribe toggle would very quickly create an ever-growing backlog and degrade core functionality. For the amount of engineering and risk involved, it adds **very little practical value** for the majority of deployments, which are often on low-powered, edge hardware.
If you hear speech that's actually important and worth saving/indexing for the future, **just press the transcribe button in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control. If you hear speech that's actually important and worth saving/indexing for the future, **just press the transcribe button (the microphone icon) in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control.
Other options are being considered for future versions of Frigate to add transcription options that support external `whisper` Docker containers. A single transcription service could then be shared by Frigate and other applications (for example, Home Assistant Voice), and run on more powerful machines when available. Other options are being considered for future versions of Frigate to add transcription options that support external `whisper` Docker containers. A single transcription service could then be shared by Frigate and other applications (for example, Home Assistant Voice), and run on more powerful machines when available.
+13
View File
@@ -262,6 +262,19 @@ In this example:
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups). - Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
:::note
If a user isn't getting the role you expect, enable debug logging to see exactly what headers Frigate is receiving from your proxy:
```yaml
logger:
default: info
logs:
frigate.api.auth: debug
```
:::
#### Port Considerations #### Port Considerations
**Authenticated Port (8971)** **Authenticated Port (8971)**
+3 -2
View File
@@ -20,7 +20,7 @@ Settings are organized into two scopes:
- **Global configuration**: values under <NavPath path="Settings > Global configuration" /> apply to every camera by default. This is where you set the baseline behavior for object detection, recording, snapshots, motion, and so on. - **Global configuration**: values under <NavPath path="Settings > Global configuration" /> apply to every camera by default. This is where you set the baseline behavior for object detection, recording, snapshots, motion, and so on.
- **Camera configuration**: values under <NavPath path="Settings > Camera configuration" /> apply to a single camera. Use the camera selector button at the top of these pages to choose which camera you are editing. - **Camera configuration**: values under <NavPath path="Settings > Camera configuration" /> apply to a single camera. Use the camera selector button at the top of these pages to choose which camera you are editing.
When a camera-level section is left untouched, the camera simply inherits the global values. Changing a value on a camera page **overrides** the global value for that camera only: the global setting and every other camera are unaffected. This mirrors how the YAML works, where a value set under `cameras.<name>` takes precedence over the same value set at the top level. When a camera-level section is left untouched, the camera simply inherits the global values. Changing a value on a camera page **overrides** the global value for that camera only: the global setting and every other camera are unaffected. This mirrors how the YAML works, where a value set under `cameras.<name>` takes precedence over the same value set at the top level. See [Global and Camera-Level Configuration](./config_overrides.md) for the full details, including how lists and maps are handled and which settings must be enabled globally first.
To undo an override and go back to inheriting from the parent scope, use the reset button at the bottom of the section: To undo an override and go back to inheriting from the parent scope, use the reset button at the bottom of the section:
@@ -130,7 +130,8 @@ go2rtc:
```yaml ```yaml
genai: genai:
api_key: "{FRIGATE_GENAI_API_KEY}" my_provider:
api_key: "{FRIGATE_GENAI_API_KEY}"
``` ```
## Common configuration examples ## Common configuration examples
+244
View File
@@ -0,0 +1,244 @@
---
id: config_overrides
title: Global and Camera-Level Configuration
---
import ConfigTabs from "@site/src/components/ConfigTabs";
import TabItem from "@theme/TabItem";
import NavPath from "@site/src/components/NavPath";
Most of Frigate's configuration can be set once for all cameras and then adjusted for individual cameras. The global value acts as the default for every camera, and any camera can override it.
This page explains how that inheritance works. For a tour of the Settings UI itself, see [Frigate Configuration](./config.md).
## The basics
Set a value globally and every camera uses it. Set the same value on a camera and that camera uses its own value instead.
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Global configuration > Object detection" /> and set **Detect FPS** to `5`. Every camera now detects at 5 fps.
2. Navigate to <NavPath path="Settings > Camera configuration > Object detection" />, select the `driveway` camera, and set **Detect FPS** to `10`.
The `driveway` camera now detects at 10 fps. Every other camera still uses the global value of 5.
</TabItem>
<TabItem value="yaml">
```yaml
detect:
fps: 5 # every camera detects at 5 fps
cameras:
front_door:
ffmpeg: ...
driveway:
ffmpeg: ...
detect:
fps: 10 # except this one
```
`front_door` inherits `fps: 5`, and `driveway` uses `10`.
</TabItem>
</ConfigTabs>
## Overrides apply per value, not per section
Overriding one value in a section does not detach the rest of that section. Everything you don't set on the camera still comes from the global configuration.
<ConfigTabs>
<TabItem value="ui">
If you set a camera's **Motion threshold** but leave **Contour area** alone, only the threshold is overridden. The contour area continues to follow <NavPath path="Settings > Global configuration > Motion detection" />, and changing it there still affects that camera.
Open a section to see which values are overridden: the section header indicates how many fields differ from the global configuration.
</TabItem>
<TabItem value="yaml">
```yaml
motion:
threshold: 30
contour_area: 10
cameras:
driveway:
motion:
threshold: 40
```
The `driveway` camera ends up with `threshold: 40` and `contour_area: 10`. Only the value you wrote was overridden.
</TabItem>
</ConfigTabs>
## Returning a camera to the global value
<ConfigTabs>
<TabItem value="ui">
A camera section that has its own values shows an **Overridden** badge. To remove the override and go back to inheriting, use the **Reset to Global** button at the bottom of the section.
</TabItem>
<TabItem value="yaml">
Frigate treats a camera value as an override because it is written in the config file, not because it differs from the global value. Repeating the global value under a camera still creates an override:
```yaml
snapshots:
enabled: true
cameras:
driveway:
snapshots:
enabled: true # this is an override, even though it matches
```
If you later change the global `snapshots.enabled` to `false`, `driveway` keeps saving snapshots, because it has its own value. To make a camera follow the global value again, delete the key from the camera rather than setting it to match.
</TabItem>
</ConfigTabs>
## Lists replace, maps merge
This is the distinction that surprises people most.
**Lists are replaced entirely.** A camera's list does not add to the global list, it takes its place.
<ConfigTabs>
<TabItem value="ui">
The camera page shows the objects the camera is currently tracking, starting from the global list. Changing that selection under <NavPath path="Settings > Camera configuration > Objects" /> replaces the list for that camera, so make sure every object you want tracked is selected, not just the ones you are adding.
</TabItem>
<TabItem value="yaml">
```yaml
objects:
track:
- person
- car
cameras:
backyard:
objects:
track:
- dog # backyard tracks ONLY dog, not person or car
```
To track `dog` in addition to the global objects, list all of them on the camera.
</TabItem>
</ConfigTabs>
An empty list is a valid override, and is the normal way to opt a camera out of something:
```yaml
review:
alerts:
labels:
- person
cameras:
street:
review:
alerts:
labels: [] # this camera never creates alerts
```
**Maps are merged key by key.** A camera can add an entry without redeclaring the others.
<ConfigTabs>
<TabItem value="ui">
Adding a filter for one object under <NavPath path="Settings > Camera configuration > Objects" /> does not remove the filters inherited from <NavPath path="Settings > Global configuration > Objects" />. The camera keeps both.
</TabItem>
<TabItem value="yaml">
```yaml
objects:
filters:
person:
min_area: 5000
cameras:
driveway:
objects:
filters:
car:
min_area: 10000
```
The `driveway` camera ends up with both the `car` filter it defined and the `person` filter from the global configuration.
</TabItem>
</ConfigTabs>
## Which settings can be overridden
Most, but not all. The [full reference config](./advanced/reference.md) is the authoritative source: sections that support camera-level overrides are marked with the comment `# NOTE: Can be overridden at the camera level`. In the UI, a setting can be overridden if it appears under both <NavPath path="Settings > Global configuration" /> and <NavPath path="Settings > Camera configuration" />.
A few things worth knowing beyond that:
- Some sections are **global only** and have no camera-level equivalent, including `go2rtc`, `genai` providers, `classification`, `telemetry`, `camera_groups`, and `ui`.
- Some sections exist **only at the camera level**, such as `zones` and `onvif`.
- Some sections are **partially overridable**, meaning a camera accepts only a few of the keys available globally. `face_recognition`, `lpr`, and `audio_transcription` work this way, and the reference config notes which keys apply.
## Enrichments that must be enabled globally first
License plate recognition and face recognition are special: the global setting is not just a default, it is a switch that must be on before any camera can use the feature. Enabling one on a camera while it is disabled globally is a configuration error, and Frigate will refuse to start:
```
Camera driveway has lpr enabled but lpr is disabled at the global level of the config. You must enable lpr at the global level.
```
Enable the feature globally, then turn it off on the cameras that don't need it.
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Global configuration > License plate recognition" /> and enable **LPR**.
2. Navigate to <NavPath path="Settings > Camera configuration > License plate recognition" />, select each camera that should not run LPR, and disable the **Enable LPR** toggle.
</TabItem>
<TabItem value="yaml">
```yaml
lpr:
enabled: true
cameras:
driveway:
ffmpeg: ... # inherits lpr, enabled
backyard:
ffmpeg: ...
lpr:
enabled: false # opted out
```
</TabItem>
</ConfigTabs>
:::note
This applies only to `lpr` and `face_recognition`, because the global setting controls whether the supporting background process starts at all. Other features do not work this way. Audio transcription, for example, can be enabled on a single camera without being enabled globally.
:::
## Profiles
[Profiles](./profiles.md) add a further layer on top of everything described above. A profile is a named set of camera overrides that you can switch on and off while Frigate is running, for example to change detection and recording behavior when you leave the house.
Profiles are applied on top of a camera's already-resolved configuration, so a profile value wins over both the camera and the global value while that profile is active. Profiles cover a subset of the camera sections and do not modify your config file.
## Summary
- A camera inherits every value you don't set on it.
- Overriding one value does not detach the rest of the section.
- Writing a value on a camera overrides it, even if it matches the global value. Remove it to inherit again.
- Lists replace the global list. Maps merge into it.
- An empty list is an override, not an omission.
- `lpr` and `face_recognition` must be enabled globally before a camera can use them.
@@ -73,9 +73,13 @@ classification:
interval: 10 # also run every N seconds (optional) interval: 10 # also run every N seconds (optional)
cameras: cameras:
front: front:
crop: [0, 180, 220, 400] # [x1, y1, x2, y2] as decimals between 0 and 1, relative to the
# camera's detect resolution
crop: [0.0, 0.25, 0.3, 0.85]
``` ```
Crop coordinates are normalized: each value is a fraction of the camera's `detect` width or height, not a pixel value. Drawing the crop in the UI wizard writes these values for you.
An optional config, `save_attempts`, can be set as a key under the model name. This defines the number of classification attempts to save in the Recent Classifications tab. For state classification models, the default is 100. An optional config, `save_attempts`, can be set as a key under the model name. This defines the number of classification attempts to save in the Recent Classifications tab. For state classification models, the default is 100.
</TabItem> </TabItem>
+16 -2
View File
@@ -232,7 +232,21 @@ Once front-facing images are performing well, start choosing slightly off-angle
Start with the [Usage](#usage) section and re-read the [Model Requirements](#model-requirements) above. Start with the [Usage](#usage) section and re-read the [Model Requirements](#model-requirements) above.
1. Ensure `person` is being _detected_. A `person` will automatically be scanned by Frigate for a face. Any detected faces will appear in the Recent Recognitions tab in the Frigate UI's Face Library. 1. Enable debug logs to see exactly what Frigate is doing.
- Enable debug logs for face recognition by adding `frigate.data_processing.real_time.face: debug` to your `logger` configuration. Restart Frigate after this change.
```yaml
logger:
default: info
logs:
# highlight-next-line
frigate.data_processing.real_time.face: debug
```
- These logs report where the pipeline stopped for each `person` object, such as no face being found within the person's bounding box, the detected face being smaller than `min_area`, or a face being recognized but scoring too low.
- If you see no face-related messages at all, also add `frigate.embeddings.maintainer: debug` to confirm that the face processor was created at startup and that `person` updates are reaching it.
2. Ensure `person` is being _detected_. A `person` will automatically be scanned by Frigate for a face. Any detected faces will appear in the Recent Recognitions tab in the Frigate UI's Face Library.
If you are using a Frigate+ or `face` detecting model: If you are using a Frigate+ or `face` detecting model:
- Watch the [debug view](/usage/live#the-single-camera-view) to ensure that `face` is being detected along with `person`. - Watch the [debug view](/usage/live#the-single-camera-view) to ensure that `face` is being detected along with `person`.
@@ -242,7 +256,7 @@ Start with the [Usage](#usage) section and re-read the [Model Requirements](#mod
- Check your `detect` stream resolution and ensure it is sufficiently high enough to capture face details on `person` objects. - Check your `detect` stream resolution and ensure it is sufficiently high enough to capture face details on `person` objects.
- You may need to lower your `detection_threshold` if faces are not being detected. - You may need to lower your `detection_threshold` if faces are not being detected.
2. Any detected faces will then be _recognized_. 3. Any detected faces will then be _recognized_.
- Make sure you have trained at least one face per the recommendations above. - Make sure you have trained at least one face per the recommendations above.
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration). - Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
+83 -52
View File
@@ -9,9 +9,27 @@ import NavPath from "@site/src/components/NavPath";
## Configuration ## Configuration
A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 4 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI-Compatible section below. A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 5 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI-Compatible section below.
To use Generative AI, you must define a single provider at the global level of your Frigate configuration. If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`. `genai` is a map of named providers. Each key under `genai` is a name you choose, and its value is that provider's settings:
```yaml
genai:
my_provider: # any name you like
provider: ollama
base_url: http://localhost:11434
model: qwen3-vl:4b
roles:
- descriptions
- embeddings
- chat
```
The examples on this page all use `my_provider`, but the name is arbitrary and is only used to reference the provider elsewhere in the config (for example, `semantic_search.model`).
Each provider handles one or more **roles**: `chat`, `descriptions`, and `embeddings`. A provider handles all three by default, and each role may be assigned to exactly one provider. Define a single provider if you want it to do everything, or split the roles across several providers using the `roles` option.
If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`.
## Local Providers ## Local Providers
@@ -78,23 +96,26 @@ All llama.cpp native options can be passed through `provider_options`, including
- Set **Provider** to `llamacpp` - Set **Provider** to `llamacpp`
- Set **Base URL** to your llama.cpp server address (e.g., `http://localhost:8080`) - Set **Base URL** to your llama.cpp server address (e.g., `http://localhost:8080`)
- Set **Model** to the name of your model - Set **Model** to the name of your model
- Under **Provider Options**, set `context_size` to tell Frigate your context size so it can send the appropriate amount of information - Optionally, under **Provider Options**, set `context_size` to override the context size Frigate detects from the server
</TabItem> </TabItem>
<TabItem value="yaml"> <TabItem value="yaml">
```yaml ```yaml
genai: genai:
provider: llamacpp my_provider:
base_url: http://localhost:8080 provider: llamacpp
model: your-model-name base_url: http://localhost:8080
provider_options: model: your-model-name
context_size: 16000 # Tell Frigate your context size so it can send the appropriate amount of information. provider_options:
context_size: 16000 # Optional, overrides the context size reported by the server.
``` ```
</TabItem> </TabItem>
</ConfigTabs> </ConfigTabs>
Frigate queries the llama.cpp server for the model's context size at startup and logs it along with the other detected capabilities. If `context_size` is set in `provider_options`, that value is always used instead, even when the server reports its own.
### Ollama ### Ollama
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance. [Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance.
@@ -127,13 +148,14 @@ Note that Frigate will not automatically download the model you specify in your
```yaml ```yaml
genai: genai:
provider: ollama my_provider:
base_url: http://localhost:11434 provider: ollama
model: qwen3-vl:4b base_url: http://localhost:11434
provider_options: # other Ollama client options can be defined model: qwen3-vl:4b
keep_alive: -1 provider_options: # other Ollama client options can be defined
options: keep_alive: -1
num_ctx: 8192 # make sure the context matches other services that are using ollama options:
num_ctx: 8192 # make sure the context matches other services that are using ollama
``` ```
</TabItem> </TabItem>
@@ -149,11 +171,12 @@ For OpenAI-compatible servers (such as llama.cpp) that don't expose the configur
```yaml ```yaml
genai: genai:
provider: openai my_provider:
base_url: http://your-llama-server provider: openai
model: your-model-name base_url: http://your-llama-server
provider_options: model: your-model-name
context_size: 8192 # Specify the configured context size provider_options:
context_size: 8192 # Specify the configured context size
``` ```
This ensures Frigate uses the correct context window size when generating prompts. This ensures Frigate uses the correct context window size when generating prompts.
@@ -176,10 +199,11 @@ This ensures Frigate uses the correct context window size when generating prompt
```yaml ```yaml
genai: genai:
provider: openai my_provider:
base_url: http://your-server:port provider: openai
api_key: your-api-key # May not be required for local servers base_url: http://your-server:port
model: your-model-name api_key: your-api-key # May not be required for local servers
model: your-model-name
``` ```
</TabItem> </TabItem>
@@ -217,19 +241,21 @@ Ollama also supports [cloud models](https://ollama.com/cloud), where model infer
```yaml ```yaml
genai: genai:
provider: ollama my_provider:
base_url: http://localhost:11434 provider: ollama
model: cloud-model-name base_url: http://localhost:11434
model: cloud-model-name
``` ```
or when using Ollama Cloud directly or when using Ollama Cloud directly
```yaml ```yaml
genai: genai:
provider: ollama my_provider:
base_url: https://ollama.com provider: ollama
model: cloud-model-name base_url: https://ollama.com
api_key: your-api-key model: cloud-model-name
api_key: your-api-key
``` ```
</TabItem> </TabItem>
@@ -267,9 +293,10 @@ To start using Gemini, you must first get an API key from [Google AI Studio](htt
```yaml ```yaml
genai: genai:
provider: gemini my_provider:
api_key: "{FRIGATE_GEMINI_API_KEY}" provider: gemini
model: gemini-2.5-flash api_key: "{FRIGATE_GEMINI_API_KEY}"
model: gemini-2.5-flash
``` ```
</TabItem> </TabItem>
@@ -279,12 +306,13 @@ genai:
To use a different Gemini-compatible API endpoint, set the `provider_options` with the `base_url` key to your provider's API URL. For example: To use a different Gemini-compatible API endpoint, set the `provider_options` with the `base_url` key to your provider's API URL. For example:
```yaml {4,5} ```yaml {5,6}
genai: genai:
provider: gemini my_provider:
... provider: gemini
provider_options: ...
base_url: https://... provider_options:
base_url: https://...
``` ```
Other HTTP options are available, see the [python-genai documentation](https://github.com/googleapis/python-genai). Other HTTP options are available, see the [python-genai documentation](https://github.com/googleapis/python-genai).
@@ -318,9 +346,10 @@ To start using OpenAI, you must first [create an API key](https://platform.opena
```yaml ```yaml
genai: genai:
provider: openai my_provider:
api_key: "{FRIGATE_OPENAI_API_KEY}" provider: openai
model: gpt-4o api_key: "{FRIGATE_OPENAI_API_KEY}"
model: gpt-4o
``` ```
</TabItem> </TabItem>
@@ -336,13 +365,14 @@ To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` env
For OpenAI-compatible servers (such as llama.cpp) that don't expose the configured context size in the API response, you can manually specify the context size in `provider_options`: For OpenAI-compatible servers (such as llama.cpp) that don't expose the configured context size in the API response, you can manually specify the context size in `provider_options`:
```yaml {5,6} ```yaml {6,7}
genai: genai:
provider: openai my_provider:
base_url: http://your-llama-server provider: openai
model: your-model-name base_url: http://your-llama-server
provider_options: model: your-model-name
context_size: 8192 # Specify the configured context size provider_options:
context_size: 8192 # Specify the configured context size
``` ```
This ensures Frigate uses the correct context window size when generating prompts. This ensures Frigate uses the correct context window size when generating prompts.
@@ -377,10 +407,11 @@ To start using Azure OpenAI, you must first [create a resource](https://learn.mi
```yaml ```yaml
genai: genai:
provider: azure_openai my_provider:
base_url: https://instance.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview provider: azure_openai
model: gpt-5-mini base_url: https://instance.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview
api_key: "{FRIGATE_OPENAI_API_KEY}" model: gpt-5-mini
api_key: "{FRIGATE_OPENAI_API_KEY}"
``` ```
</TabItem> </TabItem>
+4 -3
View File
@@ -52,9 +52,10 @@ You can define custom prompts at the global level and per-object type. To config
```yaml ```yaml
genai: genai:
provider: ollama my_provider:
base_url: http://localhost:11434 provider: ollama
model: qwen3-vl:8b-instruct base_url: http://localhost:11434
model: qwen3-vl:8b-instruct
objects: objects:
genai: genai:
+1 -1
View File
@@ -196,7 +196,7 @@ services:
::: :::
See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.8.3#module-webrtc) for more information about this. See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.14#module-webrtc) for more information about this.
### Two way talk ### Two way talk
+15
View File
@@ -232,6 +232,21 @@ No. Only one profile can be active at a time. Activating a new profile automatic
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. 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.
### How do I make a YAML profile track no objects at all?
Set the tracked object list explicitly to an empty list in the profile:
```yaml
cameras:
front_door:
profiles:
home:
objects:
track: []
```
Leaving the `objects` section empty (or omitting `track`) does not clear the list. Empty sections set no fields, so the profile inherits the full tracked object list from the base config, including anything set at the global level. The same applies to other lists, such as `audio.listen`.
### Why are some settings missing when I configure a profile override? ### 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. 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.
+2 -2
View File
@@ -163,8 +163,8 @@ genai:
model: your-model-name model: your-model-name
roles: roles:
- embeddings - embeddings
- vision - descriptions
- tools - chat
semantic_search: semantic_search:
enabled: True enabled: True
+25 -2
View File
@@ -78,7 +78,7 @@ Users of the Snapcraft build of Docker cannot use storage locations outside your
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**. Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
The default shm size of **128MB** is fine for setups with **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose). The default shm size of **128MB** is fine for setups with **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose). If raising the shm size does not help, check your [process and file limits](#process-and-file-limits) as well.
The Frigate container also stores logs in shm, which can take up to **40MB**, so make sure to take this into account in your math as well. The Frigate container also stores logs in shm, which can take up to **40MB**, so make sure to take this into account in your math as well.
@@ -86,6 +86,30 @@ The Frigate container also stores logs in shm, which can take up to **40MB**, so
The shm size cannot be set per container for Home Assistant Apps. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration. The shm size cannot be set per container for Home Assistant Apps. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration.
### Process and file limits
Frigate runs many processes and opens a number of shared memory files. Installs with a large number of cameras can exceed the default limits your container runtime applies.
Hitting the PID limit logs `RuntimeError: can't start new thread`, often followed by a "Bus error" that makes it look like an shm sizing problem. Compare the current count against the max from inside the container:
```bash
cat /sys/fs/cgroup/pids.current
cat /sys/fs/cgroup/pids.max
```
If these are close, raise the limit with [`--pids-limit`](https://docs.docker.com/engine/containers/resource_constraints/) (or `service.pids_limit` in Docker Compose).
Running out of file descriptors logs `OSError: [Errno 24] Too many open files`. Raise the limit in Docker Compose:
```yaml
services:
frigate:
ulimits:
nofile:
soft: 65535
hard: 65535
```
## Extra Steps for Specific Hardware ## Extra Steps for Specific Hardware
The following sections contain additional setup steps that are only required if you are using specific hardware. If you are not using any of these hardware types, you can skip to the [Docker](#docker) installation section. The following sections contain additional setup steps that are only required if you are using specific hardware. If you are not using any of these hardware types, you can skip to the [Docker](#docker) installation section.
@@ -484,7 +508,6 @@ Generate a Frigate Docker Compose configuration based on your hardware and requi
<DockerComposeGenerator/> <DockerComposeGenerator/>
</TabItem> </TabItem>
<TabItem value="original" label="Example Docker Compose File"> <TabItem value="original" label="Example Docker Compose File">
```yaml ```yaml
+1 -1
View File
@@ -281,7 +281,7 @@ For advanced usecases, this behavior can be changed with the [RTSP URL
template](#options) option. When set, this string will override the default stream template](#options) option. When set, this string will override the default stream
address that is derived from the default behavior described above. This option supports address that is derived from the default behavior described above. This option supports
[jinja2 templates](https://jinja.palletsprojects.com/) and has the `camera` dict [jinja2 templates](https://jinja.palletsprojects.com/) and has the `camera` dict
variables from [Frigate API](../integrations/api) variables from [Frigate API](/integrations/api/frigate-http-api)
available for the template. Note that no Home Assistant state is available to the available for the template. Note that no Home Assistant state is available to the
template, only the camera dict from Frigate. template, only the camera dict from Frigate.
+1 -1
View File
@@ -16,7 +16,7 @@ MQTT requires a network connection to your broker. This is typically local, but
### `frigate/available` ### `frigate/available`
Designed to be used as an availability topic with Home Assistant. Possible message are: Designed to be used as an availability topic with Home Assistant. Possible message are:
"online": published when Frigate is running (on startup) "online": published once Frigate is running and has published its initial state. Note that this is published on every connection to the broker, so it is republished if the broker restarts or the connection drops and recovers, without Frigate itself restarting.
"stopped": published when Frigate is stopped normally "stopped": published when Frigate is stopped normally
"offline": published automatically by the MQTT broker if Frigate disconnects unexpectedly (via MQTT Will Message) "offline": published automatically by the MQTT broker if Frigate disconnects unexpectedly (via MQTT Will Message)
+234
View File
@@ -0,0 +1,234 @@
---
id: common_errors
title: Common Error Messages
---
import FaqItem from "@site/src/components/FaqItem";
This page is an index of error messages you might see in Frigate's logs, what each one means, and where to go next. It is organized by the kind of problem, not by which component logged the message.
Two things to know before you start:
- **Many of these messages come from FFmpeg, go2rtc, GPU drivers, or the operating system, not from Frigate itself.** Frigate captures and re-logs their output, so the log level shown in the Frigate UI does not always reflect the original severity.
- **Wrapped errors put the real cause on the next line.** When Frigate logs a generic message like `Error occurred when attempting to maintain recording cache`, the actual exception is logged immediately after it. When a camera's FFmpeg process exits, Frigate logs `The following ffmpeg logs include the last 100 lines prior to exit` and dumps that camera's FFmpeg output. Always read those lines, they are where the answer usually is.
## Camera connection and streams
<FaqItem id="connection-refused-no-route-to-host-401-404" question="Connection refused / No route to host / 401 Unauthorized / 404 Not Found">
These are FFmpeg errors about reaching the camera (or the go2rtc restream). `Connection refused` and `No route to host` mean nothing is listening at that address or the host is unreachable; `401 Unauthorized` is wrong credentials; `404 Not Found` is a wrong stream path (or a `restream` input pointing at a go2rtc stream name that does not exist). A camera that has hit its concurrent-connection limit can also return `refused` or `401` on a URL that works in VLC.
See [go2rtc troubleshooting](/troubleshooting/go2rtc#1-read-the-go2rtc-logs) for how to isolate the stream.
</FaqItem>
<FaqItem id="no-frames-received-in-20-seconds" question="No frames received from <camera> in 20 seconds. Exiting ffmpeg...">
FFmpeg is running but has stopped delivering video for 20 seconds, so Frigate's camera watchdog restarts it. The stream connected at least once, then went quiet: a camera reboot, a network drop, the camera evicting the connection, or a stalled decoder. If it repeats on a loop, the stream is unstable.
</FaqItem>
<FaqItem id="ffmpeg-process-crashed-unexpectedly" question="Ffmpeg process crashed unexpectedly for <camera>">
The detect FFmpeg process exited on its own. This message is only the notification; the cause is in the 100 FFmpeg log lines Frigate dumps right after it (look for a `Failed to sync surface`, `Connection refused`, codec, or audio error in that block). Related watchdog messages include `<camera> exceeded fps limit`, which means the camera is delivering frames faster than `detect.fps` (usually a camera whose real frame rate differs from what is configured).
</FaqItem>
<FaqItem id="non-monotonically-increasing-dts" question="Application provided invalid, non monotonically increasing dts to muxer">
An FFmpeg message meaning the camera sent packets with out-of-order timestamps. Because recordings are copied without re-encoding, FFmpeg cannot fix them, and the segment muxer often splits early, producing one-second segments and a cache backlog. The usual cause is a camera "Smart Codec" / H.264+ / H.265+ mode or a camera clock that jumps.
See [Recordings: segments are only 1 second long](/troubleshooting/recordings#segments-are-only-1-second-long).
</FaqItem>
<FaqItem id="bad-cseq" question="RTP: PT=xx: bad cseq (packet loss / reordering)">
An FFmpeg message meaning RTP packets arrived out of sequence, which almost always means the stream is using UDP transport. Frigate's RTSP presets force TCP, so seeing this points at a custom `input_args`, `preset-rtsp-udp`, or a go2rtc source that is not using TCP. Switch to TCP unless your camera is [UDP-only](/configuration/camera_specific#udp-only-cameras).
</FaqItem>
<FaqItem id="error-while-decoding-mb-non-existing-pps" question="error while decoding MB / non-existing PPS referenced (corrupt frames)">
FFmpeg decoder messages meaning the received video bitstream was incomplete or damaged. A few of these at every stream start are normal (the decoder connected before the first keyframe) and Frigate discards them. A continuous stream of them means real packet loss, from Wi-Fi or a saturated link, an overloaded camera, or an FFmpeg restart loop caused by another problem. Fix the underlying instability rather than the message.
</FaqItem>
<FaqItem id="could-not-find-codec-parameters" question="Could not find codec parameters for stream ... unspecified size">
An FFmpeg message meaning it probed the stream but never saw enough decodable video to determine the frame size, often because the probe window ended before the first keyframe on a long-GOP stream, or because the stream is not delivering usable video. If it is a Reolink HTTP stream, use `preset-http-reolink`, which raises the probe size for exactly this case.
</FaqItem>
## Recording
<FaqItem id="no-new-recording-segments" question="No new recording segments were created for <camera> in the last 120s">
Frigate's record watchdog is restarting the record FFmpeg process because no valid segment has reached the cache. This means the record stream is not connecting or the segments are being rejected (see the audio-codec entry below).
See [Recordings: the record stream isn't connecting](/troubleshooting/recordings#the-record-stream-isnt-connecting).
</FaqItem>
<FaqItem id="invalid-or-missing-video-stream-in-segment" question="Invalid or missing video stream in segment. Discarding.">
A cached recording segment failed validation (no readable video stream) and was deleted. The most common cause is a segment that was truncated because the record FFmpeg process was killed mid-write, so this often appears alongside, and as a consequence of, the record-stream restarts above. A segment containing only audio triggers it too.
</FaqItem>
<FaqItem id="incompatible-audio-codec" question="Recordings silently fail to save (incompatible audio codec)">
Some camera audio codecs (G.711 variants such as `pcm_alaw` and `pcm_mulaw`) cannot be stored in an MP4 container, so segments never finalize even though live view works.
See [Recordings: incompatible audio codec](/troubleshooting/recordings#incompatible-audio-codec-recordings-silently-fail-to-save) for the FFmpeg preset that transcodes the audio to AAC.
</FaqItem>
<FaqItem id="error-maintaining-recording-cache" question="Error occurred when attempting to maintain recording cache">
A generic wrapper; the real exception is on the next log line. Frequently it is `[Errno 28] No space left on device` or `[Errno 17] File exists` on a network share.
See [Recordings cache warnings and errors](/troubleshooting/recordings#i-see-the-message-error--error-occurred-when-attempting-to-maintain-recording-cache), which covers this message and the common `Errno` cases.
</FaqItem>
## Hardware acceleration
<FaqItem id="failed-to-sync-surface" question="Failed to sync surface / Failed to download frame: -5 / Error while filtering">
A VAAPI/QSV hardware frame-sync failure between FFmpeg and the GPU driver, not a Frigate bug. It usually appears when the detect stream is being scaled or decoded on the GPU.
See [GPU: Failed to download frame: -5](/troubleshooting/gpu#failed-to-download-frame--5), which lists the fixes in order (switch VAAPI/QSV preset, change `LIBVA_DRIVER_NAME`, use an H.264 substream, match detect resolution and fps to the stream).
</FaqItem>
<FaqItem id="no-decoder-surfaces-left" question="No decoder surfaces left / Can't allocate a surface">
Both mean the GPU ran out of decode surfaces: `No decoder surfaces left` is NVIDIA NVDEC, `Can't allocate a surface` is Intel QSV. This is surface-pool exhaustion, typically from too many concurrent hardware-decoded cameras on one GPU (consumer NVIDIA cards have a driver-enforced limit on simultaneous decode sessions). Reduce the number of cameras decoding on that GPU, decode some on the CPU, or move to hardware without the session cap.
</FaqItem>
<FaqItem id="nvidia-container-cli-nvml-error" question="nvidia-container-cli: nvml error: driver not loaded">
This comes from the NVIDIA container runtime while starting the container, not from Frigate, and the container never starts. The NVIDIA driver is not loaded on the host. Confirm `nvidia-smi` works on the host itself (not inside the container) before troubleshooting Frigate. In a VM or LXC, the driver must be available inside the guest. See [Hardware: Nvidia GPU](/configuration/hardware_acceleration_video).
</FaqItem>
## Detectors and models
<FaqItem id="illegal-instruction" question="Illegal instruction (core dumped)">
The process was killed by the CPU for executing an unsupported instruction. There are two distinct causes in Frigate:
- **A Coral EdgeTPU** on a newer kernel with an outdated gasket driver. See [EdgeTPU: Illegal instruction](/troubleshooting/edgetpu#attempting-to-load-tpu-as-pci--fatal-python-error-illegal-instruction).
- **A CPU without AVX/AVX2**, when enabling semantic search, face recognition, license plate recognition, classification, or audio transcription. These features use libraries compiled with AVX and crash immediately on CPUs that lack it (commonly Intel Celeron/Pentium before the 2020 Tiger Lake generation). See the [CPU requirements](/frigate/planning_setup#cpu).
</FaqItem>
<FaqItem id="onnx-invalidprotobuf" question="ONNX Runtime InvalidProtobuf / failed to load model">
ONNX Runtime could not parse the model file. The file exists but its contents are not a valid ONNX model, usually a corrupted or interrupted download in `model_cache`, or the wrong file pointed at by `model.path`. Delete the cached model file so Frigate re-downloads it, and confirm `model.path` points at an actual `.onnx` model. See [ONNX detector configuration](/configuration/object_detectors#onnx).
</FaqItem>
<FaqItem id="cuda-failure-999-901" question="CUDA failure 999 / CUDA failure 901">
ONNX Runtime CUDA errors. `999` (`cudaErrorUnknown`) is a general, unrecoverable CUDA context failure, usually a driver/runtime version mismatch between the host and the container or a GPU in a bad state. `901` is a CUDA-graph capture error, which points at a custom model whose operations are not capture-safe. For `999`, align the host driver with the container's CUDA version and confirm the GPU is healthy.
</FaqItem>
<FaqItem id="openvino-no-supported-devices" question="Can't get OPTIMIZATION_CAPABILITIES property as no supported devices found">
OpenVINO could not find the configured device (usually `GPU` or `NPU`). Most often the `/dev/dri` render node is not passed into the container, or the wrong render node is mapped when an iGPU and a discrete GPU coexist.
See [GPU: no supported devices found](/troubleshooting/gpu#cant-get-optimization_capabilities-property-as-no-supported-devices-found).
</FaqItem>
## Memory and storage
<FaqItem id="fatal-python-error-bus-error" question="Fatal Python error: Bus error">
Frigate ran out of shared memory (`/dev/shm`). The container's `shm_size` is too small for the number and resolution of your detect streams, or you added cameras after startup without increasing it.
See [Calculating required shm-size](/frigate/installation#calculating-required-shm-size). If you cannot increase `shm_size`, lowering the `SHM_MAX_FRAMES` environment variable reduces how many frames Frigate buffers per camera.
</FaqItem>
<FaqItem id="errno-28-no-space-left" question="[Errno 28] No space left on device">
A filesystem is full: the recordings volume (`/media/frigate`), the cache tmpfs (`/tmp/cache`), or `/dev/shm`. Check which one, and note that inode exhaustion can produce this while `df -h` still shows free space.
See [Recordings: No space left on device](/troubleshooting/recordings#i-see-the-message-error--error-occurred-when-attempting-to-maintain-recording-cache).
</FaqItem>
<FaqItem id="container-exits-with-no-logs" question="The container exits or restarts with no error in the logs">
A silent exit is usually the host or container out-of-memory killer. Because `/dev/shm` and `/tmp/cache` are memory-backed, they count against the container's memory limit, so aggressive shm or cache sizing can trigger it. Give the container more memory, or reduce shm/cache sizing, and check the host's OOM messages (`dmesg`).
</FaqItem>
## Database
<FaqItem id="database-is-locked" question="database is locked">
SQLite could not acquire the write lock. Frigate's timeout already scales with camera count, so under normal local-disk operation this essentially only happens when the database is on a network share (SMB/NFS), where file locking is unreliable, or when two instances point at the same file.
See [Database is locked](/troubleshooting/faqs#error-database-is-locked).
</FaqItem>
<FaqItem id="database-disk-image-is-malformed" question="database disk image is malformed">
The SQLite database file is corrupted, typically after hard power loss, a network-share database, or a filesystem with unsafe write semantics. Frigate does not repair it automatically, but the database can usually be recovered by hand.
**Stop Frigate first**, then work on the database file directly (by default `/config/frigate.db`). Start by checking what is actually wrong:
```bash
sqlite3 frigate.db "PRAGMA integrity_check;"
```
If the only problems reported are index-related (lines such as `row 14 missing from index recordings_path` or `non-unique entry in index ...`), rebuilding the indexes is usually enough and is the least destructive fix:
```bash
sqlite3 frigate.db "REINDEX;"
```
If the integrity check reports page or byte-level corruption instead (for example `Multiple uses for byte 2706 of page 142272`), dump the readable contents into a new database:
```bash
# dump what can still be read
sqlite3 frigate.db .dump > frigate.dump
# keep the corrupt file, then rebuild from the dump
mv frigate.db frigate.db.bak
cat frigate.dump | sqlite3 frigate.db
# confirm the rebuilt database is clean, this should print "ok"
sqlite3 frigate.db "PRAGMA integrity_check;"
```
Rows stored in the corrupted pages cannot be recovered, so expect to lose some tracked objects, review items, or thumbnails. Recordings themselves are files on disk and are not affected.
As a last resort, stop Frigate, delete `frigate.db`, and restart. Frigate recreates it, but existing recordings lose all of their metadata. If a `backup.db` exists next to your database, Frigate wrote it before the last schema migration and restoring it recovers everything up to that point.
Repeat corruption usually points at the underlying storage: move the database off a network share, and on Raspberry Pi check power delivery and the SD card or SSD.
</FaqItem>
## Startup and web access
<FaqItem id="unable-to-start-frigate-in-safe-mode" question="Unable to start Frigate in safe mode / Starting Frigate in safe mode">
When your config fails validation at startup, Frigate prints the validation errors (with line numbers), then starts in **safe mode**: a minimal configuration with no cameras and MQTT disabled, so the UI stays reachable. In safe mode the only available page is the Config Editor, which shows the validation errors so you can fix them, then save and restart. Note that recording retention and storage cleanup do **not** run while in safe mode, so do not leave a low-disk system sitting in it.
`Unable to start Frigate in safe mode` means even the minimal config failed, which points at an error in your `auth`, `proxy`, or `database` section, or a config file that is not valid YAML at all. Safe mode is not sticky; fix the config and restart and Frigate returns to normal.
</FaqItem>
<FaqItem id="502-bad-gateway" question="502 Bad Gateway / connection refused to 127.0.0.1:5001">
The web server is up but the Frigate backend (port 5001) is not answering yet. By far the most common reason is that the page was loaded during startup: the API binds last, after database migrations (which can take minutes on a large database), model downloads, and process startup, while the web server is already serving. Wait for startup to finish. If it persists, the backend has failed to start, and the reason is earlier in the logs. This also explains a `connection refused to 127.0.0.1:5001` seen while loading `/ws`, because every authenticated request first makes an auth subrequest to that port.
</FaqItem>
+16
View File
@@ -428,3 +428,19 @@ You'll want to:
- [Tune your motion detection settings](/configuration/motion_detection) either by editing your config file or by using the UI's Motion Tuner. - [Tune your motion detection settings](/configuration/motion_detection) either by editing your config file or by using the UI's Motion Tuner.
</FaqItem> </FaqItem>
<FaqItem id="my-timeline-previews-are-black-after-restarting-frigate-or-recreating-the-container" question="My timeline previews are black after restarting Frigate or recreating the container. Why?">
The scrubbing previews (the timelapse clips shown when dragging the History timeline, the secondary-camera previews, and the preview that plays when hovering a review card) are not recorded continuously. Frigate caches low-resolution preview frames in `/tmp/cache` throughout each hour and only assembles them into a finished preview clip **at the top of the hour**.
In the recommended configuration, `/tmp/cache` is a small in-memory (`tmpfs`) area. When Frigate starts, it tries to restore the current hour's cached frames, so a **soft restart from the UI** preserves them. But if you recreate the Docker container or stop Frigate forcibly by any other means partway through an hour, the in-memory cache is discarded, so no preview clip is produced for that partial hour.
This is expected behavior, not a bug:
- Previews for hours that already completed and were written to disk are unaffected.
- The next full hour after a restart will generate previews normally.
- This is unrelated to `shm_size`; increasing shared memory does not change it.
To avoid the gap, use the **Restart Frigate** button in the UI's Settings menu rather than recreating the container when possible.
</FaqItem>
+1 -1
View File
@@ -34,7 +34,7 @@ All of your exports live on the **Exports** page, reachable from the main naviga
- **Rename** it, and - **Rename** it, and
- **Delete** it: deleting is the only way an export is removed. - **Delete** it: deleting is the only way an export is removed.
You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases). You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases). To download multiple exports as a zip archive, add them to a **case** and use the Download button there.
## Cases ## Cases
+2
View File
@@ -30,6 +30,7 @@ const sidebars: SidebarsConfig = {
], ],
Configuration: [ Configuration: [
"configuration/config", "configuration/config",
"configuration/config_overrides",
{ {
type: "category", type: "category",
label: "Detectors", label: "Detectors",
@@ -165,6 +166,7 @@ const sidebars: SidebarsConfig = {
], ],
Troubleshooting: [ Troubleshooting: [
"troubleshooting/faqs", "troubleshooting/faqs",
"troubleshooting/common_errors",
"troubleshooting/go2rtc", "troubleshooting/go2rtc",
"troubleshooting/recordings", "troubleshooting/recordings",
"troubleshooting/dummy-camera", "troubleshooting/dummy-camera",
+5
View File
@@ -8244,6 +8244,11 @@ components:
properties: properties:
provider: provider:
$ref: '#/components/schemas/GenAIProviderEnum' $ref: '#/components/schemas/GenAIProviderEnum'
name:
anyOf:
- type: string
- type: 'null'
title: Name
api_key: api_key:
anyOf: anyOf:
- type: string - type: string
+16 -16
View File
@@ -31,6 +31,7 @@ from frigate.api.auth import (
get_allowed_cameras_for_filter, get_allowed_cameras_for_filter,
require_role, require_role,
) )
from frigate.api.config_util import swap_runtime_config
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.request.app_body import ( from frigate.api.defs.request.app_body import (
AppConfigSetBody, AppConfigSetBody,
@@ -195,7 +196,7 @@ def genai_models(request: Request):
"before saving the configuration." "before saving the configuration."
), ),
) )
async def genai_probe(body: GenAIProbeBody): async def genai_probe(request: Request, body: GenAIProbeBody):
load_providers() load_providers()
provider_cls = PROVIDERS.get(body.provider) provider_cls = PROVIDERS.get(body.provider)
@@ -205,6 +206,13 @@ async def genai_probe(body: GenAIProbeBody):
content={"success": False, "message": "Unknown provider"}, content={"success": False, "message": "Unknown provider"},
) )
api_key = body.api_key
if api_key == REDACTED_CREDENTIAL_SENTINEL:
saved_cfg = (
request.app.frigate_config.genai.get(body.name) if body.name else None
)
api_key = saved_cfg.api_key if saved_cfg else None
# The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via # The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via
# provider_options; other plugins use GenAIClient.timeout passed below. # provider_options; other plugins use GenAIClient.timeout passed below.
# Don't inject timeout for Gemini — its HttpOptions interprets the value # Don't inject timeout for Gemini — its HttpOptions interprets the value
@@ -216,7 +224,7 @@ async def genai_probe(body: GenAIProbeBody):
try: try:
transient_cfg = GenAIConfig( transient_cfg = GenAIConfig(
provider=body.provider, provider=body.provider,
api_key=body.api_key, api_key=api_key,
base_url=body.base_url, base_url=body.base_url,
provider_options=probe_provider_options, provider_options=probe_provider_options,
# model is required by the schema but irrelevant for listing. # model is required by the schema but irrelevant for listing.
@@ -915,19 +923,7 @@ def config_set(request: Request, body: AppConfigSetBody):
if body.requires_restart == 0 or body.update_topic: if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config old_config: FrigateConfig = request.app.frigate_config
request.app.frigate_config = config swap_runtime_config(request.app, config)
request.app.genai_manager.update_config(config)
if request.app.profile_manager is not None:
request.app.profile_manager.update_config(config)
if request.app.stats_emitter is not None:
request.app.stats_emitter.config = config
if request.app.dispatcher is not None:
request.app.dispatcher.config = config
for comm in request.app.dispatcher.comms:
comm.config = config
if body.update_topic: if body.update_topic:
if body.update_topic.startswith("config/cameras/"): if body.update_topic.startswith("config/cameras/"):
@@ -971,7 +967,11 @@ def config_set(request: Request, body: AppConfigSetBody):
content=( content=(
{ {
"success": True, "success": True,
"message": "Config successfully updated, restart to apply", "message": (
"Config successfully updated"
if body.requires_restart == 0
else "Config successfully updated, restart to apply"
),
} }
), ),
status_code=200, status_code=200,
+9 -3
View File
@@ -25,6 +25,7 @@ from frigate.api.auth import (
require_go2rtc_stream_access, require_go2rtc_stream_access,
require_role, require_role,
) )
from frigate.api.config_util import swap_runtime_config
from frigate.api.defs.request.app_body import CameraSetBody from frigate.api.defs.request.app_body import CameraSetBody
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
@@ -1254,9 +1255,14 @@ async def delete_camera(
status_code=500, status_code=500,
) )
# Update runtime config # rebind every collaborator to the new config and re-layer runtime
request.app.frigate_config = config # toggles for the surviving cameras, same as /api/config/set
request.app.genai_manager.update_config(config) swap_runtime_config(request.app, config)
# drop the deleted camera's persisted overrides so a camera later
# added under the same name doesn't inherit them
if request.app.dispatcher is not None:
request.app.dispatcher.clear_runtime_state_for_camera(camera_name)
# Publish removal to stop ffmpeg processes and clean up runtime state # Publish removal to stop ffmpeg processes and clean up runtime state
request.app.config_publisher.publish_update( request.app.config_publisher.publish_update(
+35
View File
@@ -0,0 +1,35 @@
"""Shared helpers for applying a freshly parsed config to the running app."""
from fastapi import FastAPI
from frigate.config import FrigateConfig
def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
"""Point every long-lived collaborator at a newly parsed config object.
Both /api/config/set and camera deletion re-parse yaml into a fresh
FrigateConfig and must rebind the same set of references, or the API and
the dispatcher drift onto different objects (the API reports one camera
state while the dispatcher acts on another). Runtime toggle overrides are
re-layered last: the swap rebuilt every camera from yaml, so without this a
camera the user turned off would silently come back on.
"""
app.frigate_config = config
app.genai_manager.update_config(config)
if app.profile_manager is not None:
app.profile_manager.update_config(config)
if app.stats_emitter is not None:
app.stats_emitter.config = config
if app.dispatcher is not None:
app.dispatcher.config = config
for comm in app.dispatcher.comms:
comm.config = config
# workers still hold the live toggle values, so correct only the
# config object here rather than re-broadcasting every override
app.dispatcher.reapply_runtime_state_to_config()
+1
View File
@@ -14,6 +14,7 @@ class AppConfigSetBody(BaseModel):
class GenAIProbeBody(BaseModel): class GenAIProbeBody(BaseModel):
provider: GenAIProviderEnum provider: GenAIProviderEnum
name: str | None = None
api_key: str | None = None api_key: str | None = None
base_url: str | None = None base_url: str | None = None
provider_options: dict[str, Any] = Field(default_factory=dict) provider_options: dict[str, Any] = Field(default_factory=dict)
+15 -10
View File
@@ -1538,15 +1538,18 @@ async def set_description(
event.data["description"] = new_description event.data["description"] = new_description
event.save() event.save()
# If semantic search is enabled, update the index context: EmbeddingsContext | None = request.app.embeddings
if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings if context is not None:
if len(new_description) > 0: if len(new_description) > 0:
context.update_description( # If semantic search is enabled, update the index
event_id, if request.app.frigate_config.semantic_search.enabled:
new_description, context.update_description(
) event_id,
new_description,
)
else: else:
# embeddings are always cleaned up so they don't outlive their description
context.db.delete_embeddings_description(event_ids=[event_id]) context.db.delete_embeddings_description(event_ids=[event_id])
response_message = ( response_message = (
@@ -1675,9 +1678,11 @@ async def delete_single_event(event_id: str, request: Request) -> dict:
event.delete_instance() event.delete_instance()
Timeline.delete().where(Timeline.source_id == event_id).execute() Timeline.delete().where(Timeline.source_id == event_id).execute()
# If semantic search is enabled, update the index # embeddings are always cleaned up, even when semantic search is disabled,
if request.app.frigate_config.semantic_search.enabled: # so that they don't outlive their events
context: EmbeddingsContext = request.app.embeddings context: EmbeddingsContext | None = request.app.embeddings
if context is not None:
context.db.delete_embeddings_thumbnail(event_ids=[event_id]) context.db.delete_embeddings_thumbnail(event_ids=[event_id])
context.db.delete_embeddings_description(event_ids=[event_id]) context.db.delete_embeddings_description(event_ids=[event_id])
+1 -1
View File
@@ -270,7 +270,7 @@ class FrigateApp:
10 10
* len([c for c in self.config.cameras.values() if c.enabled_in_config]), * len([c for c in self.config.cameras.values() if c.enabled_in_config]),
), ),
load_vec_extension=self.config.semantic_search.enabled, load_vec_extension=True,
) )
models = [ models = [
Event, Event,
+3 -1
View File
@@ -117,7 +117,9 @@ class CameraMaintainer(threading.Thread):
if runtime: if runtime:
self.camera_metrics[name] = CameraMetrics(self.metrics_manager) self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False) self.ptz_metrics[name] = PTZMetrics(
autotracker_enabled=config.onvif.autotracking.enabled
)
self.region_grids[name] = get_camera_regions_grid( self.region_grids[name] = get_camera_regions_grid(
name, name,
config.detect, config.detect,
+2 -2
View File
@@ -111,9 +111,9 @@ class CameraState:
# draw thicker box around ptz autotracked object # draw thicker box around ptz autotracked object
if ( if (
self.camera_config.onvif.autotracking.enabled self.camera_config.onvif.autotracking.enabled
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init[ and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get(
self.name self.name
] )
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[ and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
self.name self.name
] ]
+87 -7
View File
@@ -404,38 +404,64 @@ class Dispatcher:
for comm in self.comms: for comm in self.comms:
comm.stop() comm.stop()
def restore_runtime_state(self) -> None: def apply_runtime_state(self) -> dict[str, dict[str, bool]]:
"""Replay persisted runtime overrides through the camera settings handlers. """Replay persisted runtime overrides through the camera settings handlers.
Called once after Frigate startup completes so processing threads can Routing through the handlers (rather than mutating config directly) is
receive the resulting ``config_updater`` broadcasts. Unknown cameras deliberate: they publish the ``config_updater`` broadcast and the
and topics are skipped; handler exceptions are logged and replay retained MQTT state as a side effect, so worker processes and the UI
continues for remaining entries. converge on the replayed value. Unknown cameras and topics are skipped;
handler exceptions are logged and replay continues for the rest.
Returns:
The entries handed to a handler without raising, keyed by camera
then topic. A handler can still refuse the value internally (an ON
payload for a camera that is not enabled_in_config, for example),
so this is not proof the override took effect.
""" """
state = self._runtime_state.load() state = self._runtime_state.load()
applied: dict[str, dict[str, bool]] = {}
for camera_name, features in state.items(): for camera_name, features in state.items():
if camera_name not in self.config.cameras: if camera_name not in self.config.cameras:
continue continue
for topic, value in features.items(): for topic, value in features.items():
handler = self._camera_settings_handlers.get(topic) handler = self._camera_settings_handlers.get(topic)
if handler is None: if handler is None:
continue continue
payload = "ON" if value else "OFF" payload = "ON" if value else "OFF"
try: try:
handler(camera_name, payload) handler(camera_name, payload)
except Exception: except Exception:
logger.exception( logger.exception(
"Failed to restore runtime state %s.%s=%s", "Failed to apply runtime state %s.%s=%s",
camera_name, camera_name,
topic, topic,
payload, payload,
) )
continue continue
applied.setdefault(camera_name, {})[topic] = value
return applied
def restore_runtime_state(self) -> None:
"""Replay persisted runtime overrides once Frigate startup completes.
Called after every ``config_updater`` subscriber is up so the resulting
broadcasts are not dropped by ZMQ PUB/SUB.
"""
for camera_name, features in self.apply_runtime_state().items():
for topic, value in features.items():
logger.info( logger.info(
"Restored runtime state: %s.%s=%s", "Restored runtime state: %s.%s=%s",
camera_name, camera_name,
topic, topic,
payload, "ON" if value else "OFF",
) )
def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None: def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
@@ -458,6 +484,56 @@ class Dispatcher:
""" """
self._runtime_state.clear_all() self._runtime_state.clear_all()
def clear_runtime_state_for_camera(self, camera: str) -> None:
"""Drop all persisted runtime overrides for a deleted camera.
Called by camera deletion so a camera later added under the same name
does not inherit the removed camera's stale toggles.
"""
self._runtime_state.clear_camera(camera)
def reapply_runtime_state_to_config(self) -> None:
"""Re-apply persisted runtime overrides to the swapped-in config object.
After config/set (or a camera delete) parses fresh yaml and swaps the
config, the worker processes still hold the live toggle values and the
overrides are already on disk, so only the in-process config object is
out of date. Unlike apply_runtime_state (used at startup, where workers
must be told), this makes no ZMQ, MQTT, or disk writes, it just corrects
the config the API and dispatcher read.
The field mutations and gates mirror the _on_*_command handlers; keep
the two in sync if a tracked toggle is added or its gate changes.
"""
state = self._runtime_state.load()
for camera_name, features in state.items():
camera = self.config.cameras.get(camera_name)
if camera is None:
continue
for topic, value in features.items():
if topic == "enabled":
if value and not camera.enabled_in_config:
continue
camera.enabled = value
elif topic == "detect":
camera.detect.enabled = value
# detection requires motion, mirror the handler coupling
if value and not camera.motion.enabled:
camera.motion.enabled = True
elif topic == "snapshots":
camera.snapshots.enabled = value
elif topic == "recordings":
if value and not camera.record.enabled_in_config:
continue
camera.record.enabled = value
elif topic == "audio":
if value and not camera.audio.enabled_in_config:
continue
camera.audio.enabled = value
def _on_detect_command(self, camera_name: str, payload: str) -> None: def _on_detect_command(self, camera_name: str, payload: str) -> None:
"""Callback for detect topic.""" """Callback for detect topic."""
detect_settings = self.config.cameras[camera_name].detect detect_settings = self.config.cameras[camera_name].detect
@@ -588,6 +664,10 @@ class Dispatcher:
self.ptz_metrics[camera_name].start_time.value = 0 self.ptz_metrics[camera_name].start_time.value = 0
ptz_autotracker_settings.enabled = False ptz_autotracker_settings.enabled = False
self.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera_name),
ptz_autotracker_settings,
)
self.publish(f"{camera_name}/ptz_autotracker/state", payload, retain=True) self.publish(f"{camera_name}/ptz_autotracker/state", payload, retain=True)
def _on_motion_contour_area_command(self, camera_name: str, payload: int) -> None: def _on_motion_contour_area_command(self, camera_name: str, payload: int) -> None:
+19
View File
@@ -96,6 +96,25 @@ class RuntimeStatePersistence:
except OSError: except OSError:
logger.exception("Failed to clear runtime state") logger.exception("Failed to clear runtime state")
def clear_camera(self, camera: str) -> None:
"""Drop every stored override for a single camera.
Called when a camera is deleted so a camera later added under the same
name does not inherit the removed camera's stale toggles.
"""
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
data = self._read_locked()
cameras = data.get("cameras")
if not isinstance(cameras, dict) or camera not in cameras:
return
del cameras[camera]
self._write_locked(data)
except Timeout:
logger.error("Timed out clearing runtime state for camera")
except OSError:
logger.exception("Failed to clear runtime state for camera")
def clear_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None: def clear_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
"""Remove stored entries whose YAML key was just rewritten. """Remove stored entries whose YAML key was just rewritten.
-2
View File
@@ -23,7 +23,6 @@ from frigate.const import (
EXPIRE_AUDIO_ACTIVITY, EXPIRE_AUDIO_ACTIVITY,
INSERT_MANY_RECORDINGS, INSERT_MANY_RECORDINGS,
INSERT_PREVIEW, INSERT_PREVIEW,
NOTIFICATION_TEST,
REQUEST_REGION_GRID, REQUEST_REGION_GRID,
UPDATE_AUDIO_ACTIVITY, UPDATE_AUDIO_ACTIVITY,
UPDATE_AUDIO_TRANSCRIPTION_STATE, UPDATE_AUDIO_TRANSCRIPTION_STATE,
@@ -57,7 +56,6 @@ _WS_BLOCKED_TOPICS = frozenset(
UPDATE_EMBEDDINGS_REINDEX_PROGRESS, UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_BIRDSEYE_LAYOUT, UPDATE_BIRDSEYE_LAYOUT,
UPDATE_AUDIO_TRANSCRIPTION_STATE, UPDATE_AUDIO_TRANSCRIPTION_STATE,
NOTIFICATION_TEST,
} }
) )
+3
View File
@@ -14,6 +14,7 @@ class CameraConfigUpdateEnum(str, Enum):
add = "add" # for adding a camera add = "add" # for adding a camera
audio = "audio" audio = "audio"
audio_transcription = "audio_transcription" audio_transcription = "audio_transcription"
autotracking = "autotracking" # ptz autotracking only, without an onvif reinit
birdseye = "birdseye" birdseye = "birdseye"
detect = "detect" detect = "detect"
enabled = "enabled" enabled = "enabled"
@@ -145,6 +146,8 @@ class CameraConfigUpdateSubscriber:
config.snapshots = updated_config config.snapshots = updated_config
elif update_type == CameraConfigUpdateEnum.onvif: elif update_type == CameraConfigUpdateEnum.onvif:
config.onvif = updated_config config.onvif = updated_config
elif update_type == CameraConfigUpdateEnum.autotracking:
config.onvif.autotracking = updated_config
elif update_type == CameraConfigUpdateEnum.timestamp_style: elif update_type == CameraConfigUpdateEnum.timestamp_style:
config.timestamp_style = updated_config config.timestamp_style = updated_config
elif update_type == CameraConfigUpdateEnum.zones: elif update_type == CameraConfigUpdateEnum.zones:
+1 -1
View File
@@ -640,7 +640,7 @@ class FrigateConfig(FrigateBaseModel):
# set notifications state # set notifications state
self.notifications.enabled_in_config = self.notifications.enabled self.notifications.enabled_in_config = self.notifications.enabled
# validate genai: each role (tools, vision, embeddings) at most once # validate genai: each role (chat, descriptions, embeddings) at most once
role_to_name: dict[GenAIRoleEnum, str] = {} role_to_name: dict[GenAIRoleEnum, str] = {}
for name, genai_cfg in self.genai.items(): for name, genai_cfg in self.genai.items():
for role in genai_cfg.roles: for role in genai_cfg.roles:
+5 -4
View File
@@ -141,6 +141,11 @@ class ProfileManager:
Preserves active profile state: re-snapshots base configs from the new Preserves active profile state: re-snapshots base configs from the new
(freshly parsed) config, then re-applies profile overrides if a profile (freshly parsed) config, then re-applies profile overrides if a profile
was active. was active.
Deliberately does not clear the dispatcher's runtime overrides. This is
the config-save path, not a profile switch: the save only invalidates
the toggles it rewrote in yaml, which /api/config/set already clears by
key. The broad wipe belongs to activate_profile alone.
""" """
current_active = self.config.active_profile current_active = self.config.active_profile
self.config = new_config self.config = new_config
@@ -164,10 +169,6 @@ class ProfileManager:
self.config.active_profile = None self.config.active_profile = None
self._persist_active_profile(None) self._persist_active_profile(None)
# 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( def activate_profile(
self, self,
profile_name: str | None, profile_name: str | None,
@@ -288,6 +288,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
max(0, face_box[0]) : min(frame.shape[1], face_box[2]), max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
] ]
if face_frame.size == 0:
logger.debug(f"Empty face crop for {id}")
return
res = self.recognizer.classify(face_frame) res = self.recognizer.classify(face_frame)
if not res: if not res:
+35 -6
View File
@@ -1,9 +1,12 @@
import logging
import sqlite3 import sqlite3
from typing import Any from typing import Any
import regex import regex
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
logger = logging.getLogger(__name__)
REGEXP_TIMEOUT_SECONDS = 1.0 REGEXP_TIMEOUT_SECONDS = 1.0
@@ -28,8 +31,14 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
def _load_vec_extension(self, conn: sqlite3.Connection) -> None: def _load_vec_extension(self, conn: sqlite3.Connection) -> None:
conn.enable_load_extension(True) conn.enable_load_extension(True)
conn.load_extension(self.sqlite_vec_path)
conn.enable_load_extension(False) try:
conn.load_extension(self.sqlite_vec_path)
except conn.OperationalError:
logger.error("Unable to load the sqlite-vec extension")
self.load_vec_extension = False
finally:
conn.enable_load_extension(False)
def _register_regexp(self, conn: sqlite3.Connection) -> None: def _register_regexp(self, conn: sqlite3.Connection) -> None:
def regexp(expr: str, item: str | None) -> bool: def regexp(expr: str, item: str | None) -> bool:
@@ -44,13 +53,33 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
conn.create_function("REGEXP", 2, regexp) conn.create_function("REGEXP", 2, regexp)
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None: def _delete_embeddings(self, table: str, event_ids: list[str]) -> None:
"""Delete embeddings for the given events, if the table exists.
Embeddings outlive the events they belong to when semantic search is
disabled, so deletes are attempted regardless of the current config.
"""
if not event_ids or not self.load_vec_extension:
return
# the embeddings tables are only created once semantic search has run
cursor = self.execute_sql(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
)
if cursor.fetchone() is None:
logger.debug("Skipping %s cleanup, table does not exist", table)
return
ids = ",".join(["?" for _ in event_ids]) ids = ",".join(["?" for _ in event_ids])
self.execute_sql(f"DELETE FROM vec_thumbnails WHERE id IN ({ids})", event_ids) self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
self._delete_embeddings("vec_thumbnails", event_ids)
def delete_embeddings_description(self, event_ids: list[str]) -> None: def delete_embeddings_description(self, event_ids: list[str]) -> None:
ids = ",".join(["?" for _ in event_ids]) self._delete_embeddings("vec_descriptions", event_ids)
self.execute_sql(f"DELETE FROM vec_descriptions WHERE id IN ({ids})", event_ids)
def drop_embeddings_tables(self) -> None: def drop_embeddings_tables(self) -> None:
self.execute_sql(""" self.execute_sql("""
+1 -1
View File
@@ -93,7 +93,7 @@ class ModelConfig(BaseModel):
model_type: ModelTypeEnum = Field( model_type: ModelTypeEnum = Field(
default=ModelTypeEnum.ssd, default=ModelTypeEnum.ssd,
title="Object Detection Model Type", title="Object Detection Model Type",
description="Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization.", description="Detector model architecture type (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine) used by some detectors for optimization.",
) )
_merged_labelmap: dict[int, str] | None = PrivateAttr() _merged_labelmap: dict[int, str] | None = PrivateAttr()
_colormap: dict[int, tuple[int, int, int]] = PrivateAttr() _colormap: dict[int, tuple[int, int, int]] = PrivateAttr()
+5 -4
View File
@@ -366,9 +366,10 @@ class EventCleanup(threading.Thread):
logger.debug(f"Deleting {len(chunk)} events from the database") logger.debug(f"Deleting {len(chunk)} events from the database")
Event.delete().where(Event.id << chunk).execute() Event.delete().where(Event.id << chunk).execute()
if self.config.semantic_search.enabled: # embeddings are always cleaned up, even when semantic search
self.db.delete_embeddings_description(event_ids=chunk) # is disabled, so that they don't outlive their events
self.db.delete_embeddings_thumbnail(event_ids=chunk) self.db.delete_embeddings_description(event_ids=chunk)
logger.debug(f"Deleted {len(ids_to_delete)} embeddings") self.db.delete_embeddings_thumbnail(event_ids=chunk)
logger.debug(f"Deleted {len(chunk)} embeddings")
logger.info("Exiting event cleanup...") logger.info("Exiting event cleanup...")
+1 -1
View File
@@ -192,7 +192,7 @@ class LlamaCppClient(GenAIClient):
logger.info( logger.info(
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s", "llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
configured_model, configured_model,
self._context_size or "unknown", self.get_context_size(),
self._supports_vision, self._supports_vision,
self._supports_audio, self._supports_audio,
self._supports_tools, self._supports_tools,
+18 -8
View File
@@ -335,6 +335,7 @@ class BirdsEyeFrameManager:
self.camera_layout: list[Any] = [] self.camera_layout: list[Any] = []
self.active_cameras: set[str] = set() self.active_cameras: set[str] = set()
self.layout_camera_order: list[str] = []
self.last_output_time = 0.0 self.last_output_time = 0.0
def add_camera(self, cam: str) -> None: def add_camera(self, cam: str) -> None:
@@ -372,6 +373,13 @@ class BirdsEyeFrameManager:
if cam in self.cameras: if cam in self.cameras:
del self.cameras[cam] del self.cameras[cam]
def sort_cameras(self, cameras: set[str]) -> list[str]:
"""Sort cameras by birdseye order, falling back to name when tied."""
return sorted(
cameras,
key=lambda camera: (self.config.cameras[camera].birdseye.order, camera),
)
def clear_frame(self) -> None: def clear_frame(self) -> None:
logger.debug("Clearing the birdseye frame") logger.debug("Clearing the birdseye frame")
self.frame[:] = self.blank_frame self.frame[:] = self.blank_frame
@@ -482,6 +490,7 @@ class BirdsEyeFrameManager:
# if the layout needs to be cleared # if the layout needs to be cleared
self.camera_layout = [] self.camera_layout = []
self.active_cameras = set() self.active_cameras = set()
self.layout_camera_order = []
self.clear_frame() self.clear_frame()
frame_changed = True frame_changed = True
layout_changed = True layout_changed = True
@@ -500,21 +509,21 @@ class BirdsEyeFrameManager:
else: else:
reset_layout = True reset_layout = True
sorted_active_cameras = self.sort_cameras(active_cameras)
if not reset_layout and sorted_active_cameras != self.layout_camera_order:
logger.debug("Birdseye camera order changed")
reset_layout = True
if reset_layout: if reset_layout:
logger.debug("Resetting Birdseye layout...") logger.debug("Resetting Birdseye layout...")
self.clear_frame() self.clear_frame()
self.active_cameras = active_cameras self.active_cameras = active_cameras
self.layout_camera_order = sorted_active_cameras
layout_changed = True # Layout is changing due to reset layout_changed = True # Layout is changing due to reset
# this also converts added_cameras from a set to a list since we need # this also converts added_cameras from a set to a list since we need
# to pop elements in order # to pop elements in order
active_cameras_to_add = sorted( active_cameras_to_add = sorted_active_cameras
active_cameras,
# sort cameras by order and by name if the order is the same
key=lambda active_camera: (
self.config.cameras[active_camera].birdseye.order,
active_camera,
),
)
if len(active_cameras) == 1: if len(active_cameras) == 1:
# show single camera as fullscreen # show single camera as fullscreen
camera = active_cameras_to_add[0] camera = active_cameras_to_add[0]
@@ -780,6 +789,7 @@ class BirdsEyeFrameManager:
frame_changed, layout_changed = False, False frame_changed, layout_changed = False, False
self.active_cameras = set() self.active_cameras = set()
self.camera_layout = [] self.camera_layout = []
self.layout_camera_order = []
print(traceback.format_exc()) print(traceback.format_exc())
# if the frame was updated or the fps is too low, send frame # if the frame was updated or the fps is too low, send frame
+46 -4
View File
@@ -20,6 +20,10 @@ from norfair.camera_motion import (
from frigate.camera import PTZMetrics from frigate.camera import PTZMetrics
from frigate.comms.dispatcher import Dispatcher from frigate.comms.dispatcher import Dispatcher
from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
from frigate.const import ( from frigate.const import (
AUTOTRACKING_MAX_AREA_RATIO, AUTOTRACKING_MAX_AREA_RATIO,
AUTOTRACKING_MAX_MOVE_METRICS, AUTOTRACKING_MAX_MOVE_METRICS,
@@ -194,7 +198,9 @@ class PtzAutoTrackerThread(threading.Thread):
def run(self): def run(self):
while not self.stop_event.wait(1): while not self.stop_event.wait(1):
for camera, camera_config in self.config.cameras.items(): self.ptz_autotracker.check_for_updates()
for camera, camera_config in list(self.config.cameras.items()):
if not camera_config.enabled: if not camera_config.enabled:
continue continue
@@ -211,6 +217,7 @@ class PtzAutoTrackerThread(threading.Thread):
self.ptz_autotracker.tracked_object[camera] = None self.ptz_autotracker.tracked_object[camera] = None
self.ptz_autotracker.tracked_object_history[camera].clear() self.ptz_autotracker.tracked_object_history[camera].clear()
self.ptz_autotracker.config_subscriber.stop()
logger.info("Exiting autotracker...") logger.info("Exiting autotracker...")
@@ -244,6 +251,16 @@ class PtzAutoTracker:
self.zoom_time: dict[str, float] = {} self.zoom_time: dict[str, float] = {}
self.zoom_factor: dict[str, object] = {} self.zoom_factor: dict[str, object] = {}
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config,
self.config.cameras,
[
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.autotracking,
CameraConfigUpdateEnum.onvif,
],
)
# if cam is set to autotrack, onvif should be set up # if cam is set to autotrack, onvif should be set up
for camera, camera_config in self.config.cameras.items(): for camera, camera_config in self.config.cameras.items():
if not camera_config.enabled: if not camera_config.enabled:
@@ -260,6 +277,29 @@ class PtzAutoTracker:
# Wait for the coroutine to complete # Wait for the coroutine to complete
future.result() future.result()
def check_for_updates(self) -> None:
"""Apply camera config updates and mirror autotracking state to ptz metrics.
The camera processes read autotracker_enabled rather than the config, so it
has to follow every path that can change autotracking, not just the mqtt
toggle that writes it directly.
"""
updates = self.config_subscriber.check_for_updates()
for cameras in updates.values():
for camera in cameras:
camera_config = self.config.cameras.get(camera)
metrics = self.ptz_metrics.get(camera)
# a camera added at runtime gets its metrics from the maintainer on
# another thread, which seeds them from this same config value
if camera_config is None or metrics is None:
continue
metrics.autotracker_enabled.value = (
camera_config.onvif.autotracking.enabled
)
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str): async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
logger.debug(f"{camera}: Autotracker init") logger.debug(f"{camera}: Autotracker init")
@@ -1365,7 +1405,7 @@ class PtzAutoTracker:
camera_config = self.config.cameras[camera] camera_config = self.config.cameras[camera]
if camera_config.onvif.autotracking.enabled: if camera_config.onvif.autotracking.enabled:
if not self.autotracker_init[camera]: if not self.autotracker_init.get(camera):
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
self._autotracker_setup(camera_config, camera), self.onvif.loop self._autotracker_setup(camera_config, camera), self.onvif.loop
) )
@@ -1483,9 +1523,11 @@ class PtzAutoTracker:
} }
async def camera_maintenance(self, camera): async def camera_maintenance(self, camera):
# bail and don't check anything if we're calibrating or tracking an object # bail and don't check anything if we're not set up yet, calibrating, or
# tracking an object. a camera enabled at runtime has no autotracker_init
# entry until autotrack_object sets it up
if ( if (
not self.autotracker_init[camera] not self.autotracker_init.get(camera)
or self.calibrating[camera] or self.calibrating[camera]
or self.tracked_object[camera] is not None or self.tracked_object[camera] is not None
): ):
+10 -9
View File
@@ -344,16 +344,17 @@ class OnvifController:
autotracking_config.enabled_in_config and autotracking_config.enabled autotracking_config.enabled_in_config and autotracking_config.enabled
) )
# autotracking-only: status request and service capabilities # these are local and cost nothing to build, and autotracking can be enabled
if autotracking_enabled: # after a camera is initialized, so always create them rather than baking the
status_request = ptz.create_type("GetStatus") # current config value into init state
status_request.ProfileToken = profile.token status_request = ptz.create_type("GetStatus")
self.cams[camera_name]["status_request"] = status_request status_request.ProfileToken = profile.token
self.cams[camera_name]["status_request"] = status_request
service_capabilities_request = ptz.create_type("GetServiceCapabilities") service_capabilities_request = ptz.create_type("GetServiceCapabilities")
self.cams[camera_name]["service_capabilities_request"] = ( self.cams[camera_name]["service_capabilities_request"] = (
service_capabilities_request service_capabilities_request
) )
# setup relative move request when FOV relative movement is supported # setup relative move request when FOV relative movement is supported
if ( if (
+71
View File
@@ -132,6 +132,77 @@ class TestHttpApp(BaseTestHttp):
"models": ["fake-model-a", "fake-model-b"], "models": ["fake-model-a", "fake-model-b"],
} }
def test_genai_probe_resolves_sentinel_to_saved_api_key(self):
# After a save the UI's api_key field holds the redaction sentinel;
# the probe must substitute the saved key for the named entry instead
# of sending the literal sentinel to the provider (GH discussion 23754).
probed_keys: list[str | None] = []
class CapturingClient(GenAIClient):
def list_models(self):
probed_keys.append(self.genai_config.api_key)
return ["fake-model"]
self.minimal_config["genai"] = {
"llm": {
"provider": "openai",
"api_key": "sk-saved",
"base_url": "https://example.invalid",
"model": "fake-model",
}
}
app = super().create_app()
with (
AuthTestClient(app) as client,
patch.dict(
frigate.genai.PROVIDERS,
{GenAIProviderEnum.openai: CapturingClient},
),
):
response = client.post(
"/genai/probe",
json={
"provider": "openai",
"name": "llm",
"api_key": REDACTED_CREDENTIAL_SENTINEL,
"base_url": "https://example.invalid",
},
)
assert response.status_code == 200
assert response.json()["success"] is True
assert probed_keys == ["sk-saved"]
def test_genai_probe_sentinel_without_saved_entry_sends_no_key(self):
# If the sentinel arrives for an entry that has no saved config, the
# probe must drop the key entirely rather than leak the sentinel.
probed_keys: list[str | None] = []
class CapturingClient(GenAIClient):
def list_models(self):
probed_keys.append(self.genai_config.api_key)
return ["fake-model"]
app = super().create_app()
with (
AuthTestClient(app) as client,
patch.dict(
frigate.genai.PROVIDERS,
{GenAIProviderEnum.openai: CapturingClient},
),
):
response = client.post(
"/genai/probe",
json={
"provider": "openai",
"name": "llm",
"api_key": REDACTED_CREDENTIAL_SENTINEL,
},
)
assert response.status_code == 200
assert probed_keys == [None]
def test_genai_probe_empty_list_is_treated_as_failure(self): def test_genai_probe_empty_list_is_treated_as_failure(self):
# The plugin's list_models() returns [] on connection failure rather # The plugin's list_models() returns [] on connection failure rather
# than raising. The endpoint should surface that as success=false so # than raising. The endpoint should surface that as success=false so
+132
View File
@@ -0,0 +1,132 @@
"""Tests for the camera delete endpoint's runtime config handling."""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, Mock, patch
import ruamel.yaml
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestDeleteCameraRuntimeConfig(BaseTestHttp):
"""Deleting a camera must keep the API and dispatcher on the same config."""
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"back_yard": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 720, "width": 1280, "fps": 10},
},
},
}
def _write_config_file(self):
yaml = ruamel.yaml.YAML()
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
yaml.dump(self.minimal_config, f)
f.close()
return f.name
def _create_app_with_dispatcher(self, dispatcher):
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
dispatcher=dispatcher,
enforce_default_admin=False,
)
async def mock_get_current_user(request: Request):
return {
"username": request.headers.get("remote-user"),
"role": request.headers.get("remote-role"),
}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app, mock_publisher
@patch("frigate.api.camera.requests.delete")
@patch("frigate.api.camera.cleanup_camera_files")
@patch("frigate.api.camera.cleanup_camera_db")
@patch("frigate.api.camera.find_config_file")
def test_delete_syncs_dispatcher_and_prunes_runtime_state(
self, mock_find_config, mock_cleanup_db, mock_cleanup_files, mock_go2rtc_delete
):
"""Deleting a camera swaps every config reference and prunes its state."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
mock_cleanup_db.return_value = ({}, [])
dispatcher = MagicMock()
dispatcher.comms = []
try:
app, _ = self._create_app_with_dispatcher(dispatcher)
with AuthTestClient(app) as client:
resp = client.delete("/cameras/front_door")
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()["success"])
# the dispatcher must be moved onto the same new object the API
# now serves, and that object must no longer contain the camera
self.assertIs(dispatcher.config, app.frigate_config)
self.assertNotIn("front_door", dispatcher.config.cameras)
self.assertIn("back_yard", dispatcher.config.cameras)
# surviving cameras' overrides are re-layered onto the new object
dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
# the deleted camera's persisted overrides are pruned
dispatcher.clear_runtime_state_for_camera.assert_called_once_with(
"front_door"
)
finally:
os.unlink(config_path)
if __name__ == "__main__":
unittest.main()
@@ -91,6 +91,123 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
return app, mock_publisher return app, mock_publisher
def _create_app_with_dispatcher(self, dispatcher):
"""Create app with a mocked config publisher and a real-ish dispatcher."""
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
dispatcher=dispatcher,
enforce_default_admin=False,
)
async def mock_get_current_user(request: Request):
username = request.headers.get("remote-user")
role = request.headers.get("remote-role")
return {"username": username, "role": role}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app, mock_publisher
@patch("frigate.api.app.find_config_file")
def test_runtime_disabled_camera_survives_unrelated_save(self, mock_find_config):
"""A camera turned off at runtime stays off when another camera is saved."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
dispatcher = MagicMock()
dispatcher.comms = []
# front_door was turned off via the UI: the override is on disk, and
# yaml still says enabled: true. Stand in for the real replay, which
# reads dispatcher.config - the object the endpoint just swapped in.
def fake_reapply():
dispatcher.config.cameras["front_door"].enabled = False
dispatcher.reapply_runtime_state_to_config.side_effect = fake_reapply
try:
app, _ = self._create_app_with_dispatcher(dispatcher)
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {
"cameras": {"back_yard": {"detect": {"fps": 7}}}
},
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()["success"])
# the swap must be repaired: the new config object the API and
# dispatcher now share has to still show front_door as off
dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
self.assertFalse(app.frigate_config.cameras["front_door"].enabled)
self.assertIs(dispatcher.config, app.frigate_config)
# yaml-wins ordering: the surgical clear for rewritten keys
# must run before the replay, or a save that rewrote a toggle
# would have its old override resurrected
call_names = [name for name, _, _ in dispatcher.mock_calls]
self.assertLess(
call_names.index("clear_runtime_state_for_yaml_keys"),
call_names.index("reapply_runtime_state_to_config"),
)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_no_reapply_when_config_is_not_swapped(self, mock_find_config):
"""A restart-required save with no update topic never swaps, so no replay."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
dispatcher = MagicMock()
dispatcher.comms = []
try:
app, _ = self._create_app_with_dispatcher(dispatcher)
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"mqtt": {"host": "other"}},
"requires_restart": 1,
},
)
self.assertEqual(resp.status_code, 200)
dispatcher.reapply_runtime_state_to_config.assert_not_called()
finally:
os.unlink(config_path)
def _write_config_file(self): def _write_config_file(self):
"""Write the minimal config to a temp YAML file and return the path.""" """Write the minimal config to a temp YAML file and return the path."""
yaml = ruamel.yaml.YAML() yaml = ruamel.yaml.YAML()
+70 -1
View File
@@ -1,8 +1,10 @@
"""Test camera user and password cleanup.""" """Test camera user and password cleanup."""
import multiprocessing as mp
import unittest import unittest
from frigate.output.birdseye import get_canvas_shape from frigate.config import FrigateConfig
from frigate.output.birdseye import BirdsEyeFrameManager, get_canvas_shape
class TestBirdseye(unittest.TestCase): class TestBirdseye(unittest.TestCase):
@@ -45,3 +47,70 @@ class TestBirdseye(unittest.TestCase):
canvas_width, canvas_height = get_canvas_shape(width, height) canvas_width, canvas_height = get_canvas_shape(width, height)
assert canvas_width == width # width will be the same assert canvas_width == width # width will be the same
assert canvas_height != height assert canvas_height != height
class TestBirdseyeCameraOrder(unittest.TestCase):
"""Test that birdseye reacts to camera order changes without a restart."""
def setUp(self):
config = {
"mqtt": {"enabled": False},
"birdseye": {"enabled": True, "mode": "continuous"},
"cameras": {
camera: {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
}
for camera in ("back", "front", "side")
},
}
self.config = FrigateConfig(**config)
self.manager = BirdsEyeFrameManager(self.config, mp.Event())
# mark every camera as continuously active with no frame to draw, which
# exercises the layout without needing real yuv frames
for camera_data in self.manager.cameras.values():
camera_data["current_frame"] = None
camera_data["current_frame_time"] = 1.0
camera_data["last_active_frame"] = 1.0
def layout_order(self) -> list[str]:
"""Return the cameras in the order the current layout renders them."""
return [position[0] for row in self.manager.camera_layout for position in row]
def test_layout_uses_configured_order(self):
"""Test the layout is sorted by order, then by name when tied."""
self.config.cameras["side"].birdseye.order = 0
self.config.cameras["back"].birdseye.order = 10
self.config.cameras["front"].birdseye.order = 20
self.manager.update_frame()
assert self.layout_order() == ["side", "back", "front"]
def test_order_change_rebuilds_layout(self):
"""Test a reorder relayouts even though the active cameras are unchanged."""
self.manager.update_frame()
assert self.layout_order() == ["back", "front", "side"]
# a stable active set means only an order change can reset the layout,
# which is what a settings reorder publishes to this process
self.config.cameras["side"].birdseye.order = -10
_, layout_changed = self.manager.update_frame()
assert layout_changed
assert self.layout_order() == ["side", "back", "front"]
def test_unchanged_order_keeps_layout(self):
"""Test a repeat update with no order change doesn't reset the layout."""
self.manager.update_frame()
_, layout_changed = self.manager.update_frame()
assert not layout_changed
assert self.layout_order() == ["back", "front", "side"]
+1 -3
View File
@@ -8,9 +8,7 @@ from frigate.util.builtin import clean_camera_user_pass, escape_special_characte
class TestUserPassCleanup(unittest.TestCase): class TestUserPassCleanup(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.rtsp_with_pass = "rtsp://user:password@192.168.0.2:554/live" self.rtsp_with_pass = "rtsp://user:password@192.168.0.2:554/live"
self.rtsp_with_special_pass = ( self.rtsp_with_special_pass = "rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\\{\\}\\[\\]@@192.168.0.2:554/live"
"rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\{\}\[\]@@192.168.0.2:554/live"
)
self.rtsp_no_pass = "rtsp://192.168.0.3:554/live" self.rtsp_no_pass = "rtsp://192.168.0.3:554/live"
def test_cleanup(self): def test_cleanup(self):
+55
View File
@@ -0,0 +1,55 @@
"""Tests for the shared runtime config swap helper."""
import unittest
from unittest.mock import MagicMock
from frigate.api.config_util import swap_runtime_config
class TestSwapRuntimeConfig(unittest.TestCase):
"""swap_runtime_config rebinds every collaborator to the new config."""
def _make_app(self) -> MagicMock:
app = MagicMock()
app.dispatcher.comms = [MagicMock(), MagicMock()]
return app
def test_rebinds_all_references(self) -> None:
app = self._make_app()
config = MagicMock(name="new_config")
swap_runtime_config(app, config)
self.assertIs(app.frigate_config, config)
app.genai_manager.update_config.assert_called_once_with(config)
app.profile_manager.update_config.assert_called_once_with(config)
self.assertIs(app.stats_emitter.config, config)
self.assertIs(app.dispatcher.config, config)
for comm in app.dispatcher.comms:
self.assertIs(comm.config, config)
def test_reapplies_runtime_state_after_swap(self) -> None:
app = self._make_app()
config = MagicMock(name="new_config")
swap_runtime_config(app, config)
# the swap rebuilds cameras from yaml, so overrides must be re-layered
app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
def test_tolerates_missing_optional_collaborators(self) -> None:
app = MagicMock()
app.profile_manager = None
app.stats_emitter = None
app.dispatcher = None
config = MagicMock(name="new_config")
# must not raise when the optional collaborators are absent
swap_runtime_config(app, config)
self.assertIs(app.frigate_config, config)
app.genai_manager.update_config.assert_called_once_with(config)
if __name__ == "__main__":
unittest.main()
@@ -126,6 +126,40 @@ class TestRestoreRuntimeState(unittest.TestCase):
self.dispatcher.restore_runtime_state() self.dispatcher.restore_runtime_state()
self.handler_mocks["detect"].assert_called_once_with("front_door", "ON") self.handler_mocks["detect"].assert_called_once_with("front_door", "ON")
def test_apply_runtime_state_replays_through_handlers(self) -> None:
"""The extracted method replays every stored entry."""
with patch.object(
self.dispatcher._runtime_state,
"load",
return_value={"front_door": {"enabled": False, "detect": True}},
):
self.dispatcher.apply_runtime_state()
self.handler_mocks["enabled"].assert_called_once_with("front_door", "OFF")
self.handler_mocks["detect"].assert_called_once_with("front_door", "ON")
def test_apply_runtime_state_returns_applied_entries(self) -> None:
"""Callers get back what was replayed, for logging and assertions."""
with patch.object(
self.dispatcher._runtime_state,
"load",
return_value={"front_door": {"enabled": False}, "nope": {"enabled": True}},
):
applied = self.dispatcher.apply_runtime_state()
self.assertEqual(applied, {"front_door": {"enabled": False}})
def test_restore_runtime_state_still_replays(self) -> None:
"""The startup entry point keeps working after the extraction."""
with patch.object(
self.dispatcher._runtime_state,
"load",
return_value={"back_yard": {"snapshots": False}},
):
self.dispatcher.restore_runtime_state()
self.handler_mocks["snapshots"].assert_called_once_with("back_yard", "OFF")
class TestHandlersPersistViaSet(unittest.TestCase): class TestHandlersPersistViaSet(unittest.TestCase):
"""Verify each in-scope handler writes to the runtime state on success.""" """Verify each in-scope handler writes to the runtime state on success."""
@@ -212,6 +246,122 @@ class TestClearPassthrough(unittest.TestCase):
dispatcher.clear_runtime_state() dispatcher.clear_runtime_state()
dispatcher._runtime_state.clear_all.assert_called_once_with() dispatcher._runtime_state.clear_all.assert_called_once_with()
def test_clear_runtime_state_for_camera_passthrough(self) -> None:
dispatcher = _build_dispatcher({})
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
dispatcher.clear_runtime_state_for_camera("front_door")
dispatcher._runtime_state.clear_camera.assert_called_once_with("front_door")
class TestReapplyRuntimeStateToConfig(unittest.TestCase):
"""The silent re-apply corrects the config object with no side effects."""
def _dispatcher_with(
self, cameras: dict[str, MagicMock], state: dict
) -> Dispatcher:
dispatcher = _build_dispatcher(cameras)
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
dispatcher._runtime_state.load.return_value = state
dispatcher.publish = MagicMock()
return dispatcher
def test_mutates_every_tracked_field(self) -> None:
cameras = {"front_door": _make_camera_mock()}
dispatcher = self._dispatcher_with(
cameras,
{
"front_door": {
"enabled": False,
"detect": False,
"snapshots": False,
"recordings": False,
"audio": False,
}
},
)
dispatcher.reapply_runtime_state_to_config()
cam = cameras["front_door"]
self.assertFalse(cam.enabled)
self.assertFalse(cam.detect.enabled)
self.assertFalse(cam.snapshots.enabled)
self.assertFalse(cam.record.enabled)
self.assertFalse(cam.audio.enabled)
def test_makes_no_zmq_mqtt_or_disk_writes(self) -> None:
dispatcher = self._dispatcher_with(
{"front_door": _make_camera_mock()},
{"front_door": {"enabled": False}},
)
dispatcher.reapply_runtime_state_to_config()
dispatcher.config_updater.publish_update.assert_not_called()
dispatcher._runtime_state.set.assert_not_called()
dispatcher.publish.assert_not_called()
def test_respects_enabled_in_config_gate(self) -> None:
# an ON override for a camera disabled in yaml must not enable it
cameras = {
"front_door": _make_camera_mock(enabled=False, enabled_in_config=False)
}
dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}})
dispatcher.reapply_runtime_state_to_config()
self.assertFalse(cameras["front_door"].enabled)
def test_respects_recordings_and_audio_gates(self) -> None:
# ON overrides for recordings/audio not enabled in yaml must be ignored
cameras = {
"front_door": _make_camera_mock(
record_enabled=False,
record_enabled_in_config=False,
audio_enabled=False,
audio_enabled_in_config=False,
)
}
dispatcher = self._dispatcher_with(
cameras, {"front_door": {"recordings": True, "audio": True}}
)
dispatcher.reapply_runtime_state_to_config()
self.assertFalse(cameras["front_door"].record.enabled)
self.assertFalse(cameras["front_door"].audio.enabled)
def test_applies_on_override_when_gate_passes(self) -> None:
# a camera off in yaml but enabled_in_config keeps its runtime-on state
cameras = {
"front_door": _make_camera_mock(enabled=False, enabled_in_config=True)
}
dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}})
dispatcher.reapply_runtime_state_to_config()
self.assertTrue(cameras["front_door"].enabled)
def test_detect_on_couples_motion(self) -> None:
cam = _make_camera_mock(detect_enabled=False)
cam.motion.enabled = False
dispatcher = self._dispatcher_with(
{"front_door": cam}, {"front_door": {"detect": True}}
)
dispatcher.reapply_runtime_state_to_config()
self.assertTrue(cam.detect.enabled)
self.assertTrue(cam.motion.enabled)
def test_skips_camera_not_in_config(self) -> None:
dispatcher = self._dispatcher_with(
{"front_door": _make_camera_mock()}, {"ghost": {"enabled": False}}
)
# a stale entry for a deleted camera must be ignored, not raise
dispatcher.reapply_runtime_state_to_config()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+28
View File
@@ -491,6 +491,34 @@ class TestLlamaCppProvider(unittest.TestCase):
final = _final_message(self._run_with_lines(client, lines, MULTIMODAL_MESSAGES)) final = _final_message(self._run_with_lines(client, lines, MULTIMODAL_MESSAGES))
self.assertEqual(final["content"], "ok") self.assertEqual(final["content"], "ok")
def _validated_client(self, server_context_size, provider_options=None):
"""Build a client as if the server reported the given context size."""
cfg = GenAIConfig(
provider="llamacpp",
model="m",
base_url="http://localhost:9999",
provider_options=provider_options or {},
)
info = {
"context_size": server_context_size,
"supports_vision": False,
"supports_audio": False,
"supports_tools": False,
"supports_reasoning": False,
"media_marker": "<__media__>",
}
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
with patch.object(cls, "_get_model_info", return_value=info):
return cls(cfg, timeout=5)
def test_server_context_size_used_without_override(self):
client = self._validated_client(4096)
self.assertEqual(client.get_context_size(), 4096)
def test_provider_options_context_size_overrides_server(self):
client = self._validated_client(4096, {"context_size": 32768})
self.assertEqual(client.get_context_size(), 32768)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+147 -3
View File
@@ -21,8 +21,12 @@ class TestGpuStats(unittest.TestCase):
@patch("frigate.util.services.time.sleep") @patch("frigate.util.services.time.sleep")
@patch("frigate.util.services.time.monotonic") @patch("frigate.util.services.time.monotonic")
@patch("frigate.util.services._read_intel_drm_fdinfo") @patch("frigate.util.services._read_intel_drm_fdinfo")
def test_intel_gpu_stats_fdinfo(self, read_fdinfo, monotonic, sleep, get_names): @patch("frigate.util.services._enumerate_drm_devices")
def test_intel_gpu_stats_fdinfo(
self, drm_devices, read_fdinfo, monotonic, sleep, get_names
):
# 1 second of wall clock between snapshots # 1 second of wall clock between snapshots
drm_devices.return_value = {"0000:00:02.0": "i915"}
monotonic.side_effect = [0.0, 1.0] monotonic.side_effect = [0.0, 1.0]
get_names.return_value = {"0000:00:02.0": "Intel Graphics"} get_names.return_value = {"0000:00:02.0": "Intel Graphics"}
@@ -96,13 +100,15 @@ class TestGpuStats(unittest.TestCase):
@patch("frigate.util.services.time.sleep") @patch("frigate.util.services.time.sleep")
@patch("frigate.util.services.time.monotonic") @patch("frigate.util.services.time.monotonic")
@patch("frigate.util.services._read_intel_drm_fdinfo") @patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
def test_intel_gpu_stats_xe_capacity( def test_intel_gpu_stats_xe_capacity(
self, read_fdinfo, monotonic, sleep, get_names self, drm_devices, read_fdinfo, monotonic, sleep, get_names
): ):
# Xe engines report cumulative cycles paired with total cycles, plus a # Xe engines report cumulative cycles paired with total cycles, plus a
# per-class capacity. drm-cycles-* is summed across every instance of a # per-class capacity. drm-cycles-* is summed across every instance of a
# class, so on Battlemage (capacity 2 for vcs/vecs) busy/total must be # class, so on Battlemage (capacity 2 for vcs/vecs) busy/total must be
# divided by capacity to land in 0-100%. # divided by capacity to land in 0-100%.
drm_devices.return_value = {"0000:03:00.0": "xe"}
monotonic.side_effect = [0.0, 1.0] monotonic.side_effect = [0.0, 1.0]
get_names.return_value = {"0000:03:00.0": "Intel Arc"} get_names.return_value = {"0000:03:00.0": "Intel Arc"}
@@ -150,7 +156,145 @@ class TestGpuStats(unittest.TestCase):
}, },
} }
@patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services._read_intel_drm_fdinfo") @patch("frigate.util.services._read_intel_drm_fdinfo")
def test_intel_gpu_stats_no_clients(self, read_fdinfo): @patch("frigate.util.services._enumerate_drm_devices")
def test_intel_gpu_stats_no_clients_reports_idle(
self, drm_devices, read_fdinfo, sleep, get_names
):
# The device exists but nothing holds it open, e.g. while camera
# processes are restarting. This is an idle state, not an error:
# returning None here would latch the hwaccel error cooldown and
# blank GPU stats for an hour over a momentary gap.
drm_devices.return_value = {"0000:00:02.0": "i915"}
read_fdinfo.return_value = {} read_fdinfo.return_value = {}
get_names.return_value = {"0000:00:02.0": "Intel Graphics"}
assert get_intel_gpu_stats(None) == {
"0000:00:02.0": {
"name": "Intel Graphics",
"vendor": "intel",
"gpu": "0.0%",
"mem": "-%",
"compute": "0.0%",
"dec": "0.0%",
},
}
# Idle short-circuits before spending the sample window
sleep.assert_not_called()
read_fdinfo.assert_called_once()
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
def test_intel_gpu_stats_clients_without_engine_counters(
self, drm_devices, read_fdinfo, sleep
):
# i915 publishes drm-driver/drm-pdev/drm-client-id but no drm-engine-*
# lines while GuC submission is active on kernels older than 6.5, so
# clients are found with nothing to sample. Reporting idle here would
# be a lie, and sampling a second time cannot help.
drm_devices.return_value = {"0000:00:02.0": "i915"}
read_fdinfo.return_value = {
("0000:00:02.0", "48", "1109"): {
"driver": "i915",
"pid": "1109",
"engines": {},
},
("0000:00:02.0", "51", "1258"): {
"driver": "i915",
"pid": "1258",
"engines": {},
},
}
assert get_intel_gpu_stats(None) is None assert get_intel_gpu_stats(None) is None
sleep.assert_not_called()
read_fdinfo.assert_called_once()
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
def test_intel_gpu_stats_no_intel_device(self, drm_devices, read_fdinfo):
# Only a non-Intel GPU is visible in sysfs; /proc is never scanned
drm_devices.return_value = {"0000:01:00.0": "nvidia"}
assert get_intel_gpu_stats(None) is None
read_fdinfo.assert_not_called()
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
@patch("frigate.util.services._resolve_intel_gpu_pdev")
def test_intel_gpu_stats_unresolvable_device_hint(
self, resolve_pdev, drm_devices, read_fdinfo
):
# A configured intel_gpu_device that cannot be resolved is a config
# error, not a reason to silently fall back to reporting all GPUs
resolve_pdev.return_value = None
assert get_intel_gpu_stats("/dev/dri/renderD999") is None
drm_devices.assert_not_called()
read_fdinfo.assert_not_called()
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
@patch("frigate.util.services._resolve_intel_gpu_pdev")
def test_intel_gpu_stats_hint_resolves_to_non_intel_gpu(
self, resolve_pdev, drm_devices, read_fdinfo
):
# card numbering can reorder across reboots on multi-GPU hosts, so a
# configured hint may point at another vendor's card; call it out
# instead of reporting nothing
resolve_pdev.return_value = "0000:01:00.0"
drm_devices.return_value = {
"0000:00:02.0": "i915",
"0000:01:00.0": "nvidia",
}
assert get_intel_gpu_stats("/dev/dri/card0") is None
read_fdinfo.assert_not_called()
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
def test_intel_gpu_stats_unreadable_proc(self, drm_devices, read_fdinfo):
# A scan failure (None) is a different condition than a scan that
# finds no clients ({}) and must not report idle
drm_devices.return_value = {"0000:00:02.0": "i915"}
read_fdinfo.return_value = None
assert get_intel_gpu_stats(None) is None
@patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services.time.monotonic")
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
def test_intel_gpu_stats_clients_lost_between_samples(
self, drm_devices, read_fdinfo, monotonic, sleep, get_names
):
# Clients disappearing during the sample window is transient process
# churn, so report idle rather than latching an error
drm_devices.return_value = {"0000:00:02.0": "i915"}
monotonic.side_effect = [0.0, 1.0]
get_names.return_value = {"0000:00:02.0": "Intel Graphics"}
read_fdinfo.side_effect = [
{
("0000:00:02.0", "1", "100"): {
"driver": "i915",
"pid": "100",
"engines": {"video": (5_000_000_000, 0, 1)},
},
},
{},
]
assert get_intel_gpu_stats(None) == {
"0000:00:02.0": {
"name": "Intel Graphics",
"vendor": "intel",
"gpu": "0.0%",
"mem": "-%",
"compute": "0.0%",
"dec": "0.0%",
},
}
+23 -3
View File
@@ -786,8 +786,15 @@ class TestProfileManager(unittest.TestCase):
dispatcher.clear_runtime_state.assert_not_called() dispatcher.clear_runtime_state.assert_not_called()
@patch.object(ProfileManager, "_persist_active_profile") @patch.object(ProfileManager, "_persist_active_profile")
def test_update_config_clears_when_active_profile_reapplies(self, mock_persist): def test_update_config_preserves_runtime_state_with_active_profile(
"""After /api/config/set, an active-profile re-application drops state.""" self, mock_persist
):
"""A config/set save must not wipe overrides it never rewrote.
The save path clears matching entries itself via
clear_runtime_state_for_yaml_keys; a broad wipe here would drop
overrides for unrelated cameras.
"""
dispatcher = MagicMock() dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher) manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed") manager.activate_profile("armed")
@@ -795,7 +802,20 @@ class TestProfileManager(unittest.TestCase):
new_config = FrigateConfig(**self.config_data) new_config = FrigateConfig(**self.config_data)
manager.update_config(new_config) manager.update_config(new_config)
dispatcher.clear_runtime_state.assert_called_once_with() dispatcher.clear_runtime_state.assert_not_called()
@patch.object(ProfileManager, "_persist_active_profile")
def test_update_config_still_reapplies_active_profile(self, mock_persist):
"""Dropping the wipe must not disturb profile re-application."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed")
new_config = FrigateConfig(**self.config_data)
manager.update_config(new_config)
self.assertEqual(manager.config, new_config)
self.assertEqual(new_config.active_profile, "armed")
@patch.object(ProfileManager, "_persist_active_profile") @patch.object(ProfileManager, "_persist_active_profile")
def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist): def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist):
+130
View File
@@ -0,0 +1,130 @@
"""Tests for autotracker state that must survive runtime config changes.
Regression coverage for a family of bugs where per-camera autotracker state was
built once at startup and never revisited. A camera that is added or enabled
after startup, or has autotracking enabled from the UI, would either raise a
KeyError on the autotracker thread or silently keep the wrong state:
- autotracker_init only got an entry for cameras enabled when PtzAutoTracker was
constructed, so runtime-enabled cameras raised KeyError on lookup.
- ptz_metrics autotracker_enabled is what the camera processes read, but nothing
updated it when autotracking was enabled through a config save, so it stayed
False and the tracker never built a motion estimator.
"""
import unittest
from unittest.mock import MagicMock
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig
from frigate.ptz.autotrack import PtzAutoTracker
CAMERA = "ptz_cam"
def _config(autotracking_enabled: bool) -> FrigateConfig:
return FrigateConfig(
**{
"mqtt": {"enabled": False},
"cameras": {
CAMERA: {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"width": 1920, "height": 1080},
"zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}},
"onvif": {
"host": "10.0.0.1",
"autotracking": {
"enabled": autotracking_enabled,
"required_zones": ["zone"],
},
},
}
},
}
)
def _make_tracker(autotracking_enabled: bool = True) -> PtzAutoTracker:
"""Build a PtzAutoTracker without invoking __init__, which would try to set up
onvif over the network. Only the config/metrics state is relevant here."""
tracker = PtzAutoTracker.__new__(PtzAutoTracker)
tracker.config = _config(autotracking_enabled)
tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)}
tracker.onvif = MagicMock()
tracker.config_subscriber = MagicMock()
tracker.autotracker_init = {}
tracker.calibrating = {}
tracker.tracked_object = {}
return tracker
class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase):
async def test_camera_maintenance_returns_early_when_not_initialized(self) -> None:
# a camera enabled at runtime has no autotracker_init entry, which used to
# raise KeyError and kill the autotracker thread for every camera
tracker = _make_tracker()
self.assertNotIn(CAMERA, tracker.autotracker_init)
await tracker.camera_maintenance(CAMERA)
tracker.onvif.get_camera_status.assert_not_called()
async def test_camera_maintenance_returns_early_when_init_incomplete(self) -> None:
# autotracker_init is seeded False for enabled cameras before setup runs
tracker = _make_tracker()
tracker.autotracker_init[CAMERA] = False
await tracker.camera_maintenance(CAMERA)
tracker.onvif.get_camera_status.assert_not_called()
class TestAutotrackerMetricSync(unittest.TestCase):
def test_metric_follows_config_when_enabled_by_update(self) -> None:
# autotracking enabled via a config save: the metric was seeded False when
# the camera was added and nothing else updates it
tracker = _make_tracker(autotracking_enabled=True)
metrics = tracker.ptz_metrics[CAMERA]
self.assertFalse(metrics.autotracker_enabled.value)
tracker.config_subscriber.check_for_updates.return_value = {"onvif": [CAMERA]}
tracker.check_for_updates()
self.assertTrue(metrics.autotracker_enabled.value)
def test_metric_follows_config_when_disabled_by_update(self) -> None:
tracker = _make_tracker(autotracking_enabled=False)
metrics = tracker.ptz_metrics[CAMERA]
metrics.autotracker_enabled.value = True
tracker.config_subscriber.check_for_updates.return_value = {
"autotracking": [CAMERA]
}
tracker.check_for_updates()
self.assertFalse(metrics.autotracker_enabled.value)
def test_metric_sync_skips_camera_without_metrics(self) -> None:
# `add` reaches the maintainer and the autotracker on separate threads with
# no ordering guarantee, so the metrics may not exist yet
tracker = _make_tracker()
tracker.ptz_metrics = {}
tracker.config_subscriber.check_for_updates.return_value = {"add": [CAMERA]}
tracker.check_for_updates()
def test_metric_sync_skips_unknown_camera(self) -> None:
tracker = _make_tracker()
tracker.config_subscriber.check_for_updates.return_value = {
"add": ["not_in_config"]
}
tracker.check_for_updates()
if __name__ == "__main__":
unittest.main()
+147
View File
@@ -0,0 +1,147 @@
"""Tests for ONVIF init state that must not depend on the autotracking config.
Regression coverage for a camera that is initialized while autotracking is off and
has it enabled later, which is the normal wizard flow: set the camera up first,
configure autotracking afterwards. The autotracking-only request objects used to
be created only when autotracking was enabled at init time, so the camera was left
with init=True but no status_request. get_camera_status skips its re-init branch
when init is True, so it went straight to the missing key and raised KeyError on
the tracking thread.
The request objects are built from the locally parsed WSDL and cost no network, so
they are always created and init=True now implies they exist.
"""
import unittest
from unittest.mock import AsyncMock, MagicMock
from frigate.config import FrigateConfig
from frigate.ptz.onvif import OnvifController
CAMERA = "ptz_cam"
def _config(autotracking_enabled: bool) -> FrigateConfig:
return FrigateConfig(
**{
"mqtt": {"enabled": False},
"cameras": {
CAMERA: {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"width": 1920, "height": 1080},
"zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}},
"onvif": {
"host": "10.0.0.1",
"autotracking": {
"enabled": autotracking_enabled,
"required_zones": ["zone"],
},
},
}
},
}
)
def _make_profile() -> MagicMock:
profile = MagicMock()
profile.token = "profile_1"
profile.Name = "MainStream"
profile.VideoEncoderConfiguration = MagicMock()
ptz_config = MagicMock()
ptz_config.token = "ptz_config_1"
ptz_config.DefaultContinuousPanTiltVelocitySpace = "space"
ptz_config.DefaultContinuousZoomVelocitySpace = "space"
profile.PTZConfiguration = ptz_config
return profile
def _make_onvif_camera() -> MagicMock:
"""A camera that supports PTZ but nothing optional, so init takes the simplest
path through the feature detection below."""
onvif = MagicMock()
onvif.update_xaddrs = AsyncMock()
video_source = MagicMock()
video_source.token = "video_source_1"
media = MagicMock()
media.GetProfiles = AsyncMock(return_value=[_make_profile()])
media.GetVideoSources = AsyncMock(return_value=[video_source])
onvif.create_media_service = AsyncMock(return_value=media)
onvif.get_definition = MagicMock(return_value={"ptz": "definition"})
ptz = MagicMock()
# create_type is a local WSDL lookup, so tag the result to assert on it later
ptz.create_type = MagicMock(side_effect=lambda name: MagicMock(request_type=name))
ptz.GetConfigurationOptions = AsyncMock(side_effect=Exception("not supported"))
onvif.create_ptz_service = AsyncMock(return_value=ptz)
onvif.create_imaging_service = AsyncMock(side_effect=Exception("not supported"))
return onvif
def _make_controller(autotracking_enabled: bool) -> OnvifController:
"""Build a controller without invoking __init__, which would start an event loop
thread and reach out to the camera."""
config = _config(autotracking_enabled)
controller = OnvifController.__new__(OnvifController)
controller.config = config
controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}}
controller.failed_cams = {}
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
controller.ptz_metrics = {CAMERA: MagicMock()}
return controller
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
async def test_status_request_created_when_autotracking_disabled(self) -> None:
# the wizard flow: onvif configured first, autotracking enabled later
controller = _make_controller(autotracking_enabled=False)
self.assertTrue(await controller._init_onvif(CAMERA))
cam = controller.cams[CAMERA]
self.assertTrue(cam["init"])
self.assertIn("status_request", cam)
self.assertIn("service_capabilities_request", cam)
async def test_status_request_created_when_autotracking_enabled(self) -> None:
controller = _make_controller(autotracking_enabled=True)
self.assertTrue(await controller._init_onvif(CAMERA))
cam = controller.cams[CAMERA]
self.assertIn("status_request", cam)
self.assertIn("service_capabilities_request", cam)
async def test_init_implies_status_request_exists(self) -> None:
# the invariant get_camera_status relies on: it skips re-init when init is
# True and then reads status_request without guarding
for autotracking_enabled in (True, False):
with self.subTest(autotracking_enabled=autotracking_enabled):
controller = _make_controller(autotracking_enabled)
await controller._init_onvif(CAMERA)
cam = controller.cams[CAMERA]
if cam["init"]:
self.assertEqual(cam["status_request"].request_type, "GetStatus")
async def test_requests_built_without_contacting_camera(self) -> None:
# create_type is a local WSDL lookup; cameras that do not implement
# GetServiceCapabilities must not be asked about it during init
controller = _make_controller(autotracking_enabled=False)
await controller._init_onvif(CAMERA)
ptz = controller.cams[CAMERA]["ptz"]
ptz.GetServiceCapabilities.assert_not_called()
ptz.GetStatus.assert_not_called()
if __name__ == "__main__":
unittest.main()
+19
View File
@@ -131,6 +131,25 @@ class TestRuntimeStatePersistence(unittest.TestCase):
self.store.clear_all() self.store.clear_all()
self.assertEqual(self.store.load(), {}) self.assertEqual(self.store.load(), {})
def test_clear_camera_removes_only_that_camera(self) -> None:
self.store.set("front_door", "enabled", False)
self.store.set("front_door", "detect", False)
self.store.set("back_yard", "audio", False)
self.store.clear_camera("front_door")
self.assertEqual(self.store.load(), {"back_yard": {"audio": False}})
def test_clear_camera_is_noop_for_unknown_camera(self) -> None:
self.store.set("front_door", "enabled", False)
self.store.clear_camera("side_gate")
self.assertEqual(self.store.load(), {"front_door": {"enabled": False}})
def test_clear_camera_is_safe_when_file_missing(self) -> None:
# No prior set() calls, so the file does not exist
self.store.clear_camera("front_door")
self.assertEqual(self.store.load(), {})
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -0,0 +1,63 @@
"""Tests for embedding cleanup on the main Frigate database.
Embeddings are deleted whether or not semantic search is currently enabled, so
the delete path has to tolerate databases where the vec0 tables were never
created and installs where the sqlite-vec extension is unavailable.
"""
import os
import tempfile
import unittest
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
class TestDeleteEmbeddings(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.db = SqliteVecQueueDatabase(os.path.join(self.tmp_dir.name, "test.db"))
self.db.start()
# the extension is not available to tests, so stand in for a database
# that has it loaded and use a plain table for the deletes
self.db.load_vec_extension = True
def tearDown(self) -> None:
self.db.stop()
self.db.close()
self.tmp_dir.cleanup()
def _flush_writes(self) -> None:
# writes are queued and applied by a worker thread, and the queue is
# FIFO, so awaiting a later write means the earlier ones are done
self.db.execute_sql("PRAGMA user_version = 0").fetchall()
def _create_thumbnails_table(self) -> None:
self.db.execute_sql("CREATE TABLE vec_thumbnails (id TEXT PRIMARY KEY)")
self.db.execute_sql("INSERT INTO vec_thumbnails (id) VALUES ('a'), ('b')")
self._flush_writes()
def _thumbnail_ids(self) -> list[str]:
return [row[0] for row in self.db.execute_sql("SELECT id FROM vec_thumbnails")]
def test_delete_without_tables_does_not_raise(self) -> None:
# semantic search was never enabled, so event cleanup has nothing to do
self.db.delete_embeddings_thumbnail(event_ids=["1700000000.0-abc"])
self.db.delete_embeddings_description(event_ids=["1700000000.0-abc"])
def test_delete_removes_embeddings(self) -> None:
self._create_thumbnails_table()
self.db.delete_embeddings_thumbnail(event_ids=["a"])
self._flush_writes()
self.assertEqual(self._thumbnail_ids(), ["b"])
def test_delete_skipped_without_extension(self) -> None:
self._create_thumbnails_table()
self.db.load_vec_extension = False
self.db.delete_embeddings_thumbnail(event_ids=["a"])
self._flush_writes()
# the vec0 tables cannot be written without the extension
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
+14
View File
@@ -115,6 +115,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
) )
) )
def test_viewer_blocked_from_notification_test(self):
self.assertFalse(
_check_ws_authorization(
"notification_test", "viewer", self.DEFAULT_SEPARATOR
)
)
# --- Admin access --- # --- Admin access ---
def test_admin_can_send_restart(self): def test_admin_can_send_restart(self):
@@ -134,6 +141,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
_check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR) _check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR)
) )
def test_admin_can_send_notification_test(self):
self.assertTrue(
_check_ws_authorization(
"notification_test", "admin", self.DEFAULT_SEPARATOR
)
)
# --- Comma-separated roles --- # --- Comma-separated roles ---
def test_comma_separated_admin_viewer_grants_admin(self): def test_comma_separated_admin_viewer_grants_admin(self):
+14 -15
View File
@@ -684,22 +684,21 @@ class TrackedObjectProcessor(threading.Thread):
# check for config updates # check for config updates
updated_topics = self.camera_config_subscriber.check_for_updates() updated_topics = self.camera_config_subscriber.check_for_updates()
if "enabled" in updated_topics: # a single drain can carry several topics at once, so add and
for camera in updated_topics["enabled"]: # remove are handled independently rather than as exclusive branches
if self.camera_states[camera].prev_enabled is None: for camera in updated_topics.get("add", []):
self.camera_states[camera].prev_enabled = self.config.cameras[ self.config.cameras[camera] = (
camera self.camera_config_subscriber.camera_configs[camera]
].enabled )
elif "add" in updated_topics: self.create_camera_state(camera)
for camera in updated_topics["add"]:
self.config.cameras[camera] = ( if "remove" in updated_topics:
self.camera_config_subscriber.camera_configs[camera]
)
self.create_camera_state(camera)
elif "remove" in updated_topics:
for camera in updated_topics["remove"]: for camera in updated_topics["remove"]:
removed_camera_state = self.camera_states[camera] camera_state = self.camera_states.get(camera)
removed_camera_state.shutdown() if camera_state is None:
continue
camera_state.shutdown()
self.camera_states.pop(camera) self.camera_states.pop(camera)
self.camera_activity.pop(camera, None) self.camera_activity.pop(camera, None)
self.last_motion_detected.pop(camera, None) self.last_motion_detected.pop(camera, None)
+1 -1
View File
@@ -326,7 +326,7 @@ def get_ort_providers(
{ {
"device_id": device_id, "device_id": device_id,
"trt_fp16_enable": requires_fp16 "trt_fp16_enable": requires_fp16
and os.environ.get("USE_FP_16", "True") != "False", and os.environ.get("USE_FP16", "True") != "False",
"trt_timing_cache_enable": True, "trt_timing_cache_enable": True,
"trt_engine_cache_enable": True, "trt_engine_cache_enable": True,
"trt_timing_cache_path": os.path.join( "trt_timing_cache_path": os.path.join(
+164 -24
View File
@@ -285,6 +285,10 @@ _XE_ENGINE_KEYS = {
"vecs": "video-enhance", "vecs": "video-enhance",
"ccs": "compute", "ccs": "compute",
} }
_INTEL_DRM_DRIVERS = ("i915", "xe")
_PCI_ADDRESS_RE = re.compile(
r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$"
)
def _resolve_intel_gpu_pdev(device: str | None) -> str | None: def _resolve_intel_gpu_pdev(device: str | None) -> str | None:
@@ -294,29 +298,70 @@ def _resolve_intel_gpu_pdev(device: str | None) -> str | None:
if not device: if not device:
return None return None
if re.match(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$", device): if _PCI_ADDRESS_RE.match(device):
return device return device
name = os.path.basename(device.rstrip("/")) name = os.path.basename(device.rstrip("/"))
try: try:
return os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device")) pdev = os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device"))
except OSError: except OSError:
return None return None
# realpath does not raise on a nonexistent node; it returns the input
# path unchanged, so validate the result actually looks like a PCI
# address before trusting it.
return pdev if _PCI_ADDRESS_RE.match(pdev) else None
def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict:
def _enumerate_drm_devices() -> dict[str, str]:
"""Map each PCI-attached DRM device to its bound kernel driver.
Reads /sys/class/drm, which reflects every GPU on the host even when only
some render nodes are mapped into the container, so device presence can be
verified without /dev access. Returns {pdev: driver}, e.g.
{"0000:00:02.0": "i915"}.
"""
devices: dict[str, str] = {}
try:
entries = os.listdir("/sys/class/drm")
except OSError:
return devices
for entry in entries:
device_dir = f"/sys/class/drm/{entry}/device"
pdev = os.path.basename(os.path.realpath(device_dir))
if not _PCI_ADDRESS_RE.match(pdev):
continue
try:
driver = os.path.basename(os.readlink(f"{device_dir}/driver"))
except OSError:
continue
devices[pdev] = driver
return devices
def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict | None:
"""Snapshot DRM fdinfo for every Intel client visible in /proc. """Snapshot DRM fdinfo for every Intel client visible in /proc.
Returns a dict keyed by (pdev, drm-client-id, pid) so the same context Returns a dict keyed by (pdev, drm-client-id, pid) so the same context
seen via multiple file descriptors on a single process collapses to one seen via multiple file descriptors on a single process collapses to one
entry. entry. Clients whose fdinfo carries no engine counters are still included
with an empty "engines" dict so the caller can distinguish "clients exist
but the kernel publishes no busyness" from "no clients at all". Returns
None when /proc itself cannot be scanned, which is a different failure
than a scan that finds nothing.
""" """
snapshot: dict = {} snapshot: dict = {}
try: try:
proc_entries = os.listdir("/proc") proc_entries = os.listdir("/proc")
except OSError: except OSError:
return snapshot return None
for entry in proc_entries: for entry in proc_entries:
if not entry.isdigit(): if not entry.isdigit():
@@ -400,41 +445,124 @@ def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict:
except (ValueError, IndexError): except (ValueError, IndexError):
continue continue
if not engines:
continue
snapshot[key] = {"driver": driver, "pid": entry, "engines": engines} snapshot[key] = {"driver": driver, "pid": entry, "engines": engines}
return snapshot return snapshot
def _idle_intel_gpu_stats(
target_pdev: str | None, intel_pdevs: dict[str, str]
) -> dict[str, dict[str, Any]]:
"""Build a 0% reading for the configured (or every) Intel GPU.
Used when the device is confirmed present but no DRM client is currently
attached, e.g. while camera processes are restarting. That is an idle
state, not a collection failure, so it must produce a valid reading:
returning None would latch the hwaccel error cooldown and blank GPU stats
for an hour over a momentary gap.
"""
from frigate.stats.intel_gpu_info import intel_gpu_name_resolver
names = intel_gpu_name_resolver.get_names()
pdevs = [target_pdev] if target_pdev else sorted(intel_pdevs)
return {
pdev: {
"name": names.get(pdev) or "Intel iGPU",
"vendor": "intel",
"gpu": "0.0%",
"mem": "-%",
"compute": "0.0%",
"dec": "0.0%",
}
for pdev in pdevs
}
def get_intel_gpu_stats( def get_intel_gpu_stats(
intel_gpu_device: str | None, intel_gpu_device: str | None,
) -> dict[str, dict[str, Any]] | None: ) -> dict[str, dict[str, Any]] | None:
"""Get stats by reading DRM fdinfo files, bucketed per-pdev. """Get stats by reading DRM fdinfo files, bucketed per-pdev.
Each DRM client FD exposes monotonic per-engine busy counters via Each DRM client FD exposes monotonic per-engine busy counters via
/proc/<pid>/fdinfo/<fd> (i915 since kernel 5.19, Xe since first release). /proc/<pid>/fdinfo/<fd>. For i915 this requires kernel 6.5 or newer:
We sample twice and divide busy-time deltas by wall-clock to derive earlier kernels omit the per-engine counters whenever GuC submission is
utilization. Render/3D and Compute are pooled into "compute"; Video and active, which is the default on 12th gen and newer. Xe has exposed them
VideoEnhance into "dec". Overall "gpu" is the sum of those pools (clamped since its first release. We sample twice and divide busy-time deltas by
to 100%). wall-clock to derive utilization. Render/3D and Compute are pooled into
"compute"; Video and VideoEnhance into "dec". Overall "gpu" is the sum of
those pools (clamped to 100%).
The return value is keyed by the GPU's drm-pdev string so multiple Intel The return value is keyed by the GPU's drm-pdev string so multiple Intel
GPUs in the same system are reported separately. Each entry carries a GPUs in the same system are reported separately. Each entry carries a
"name" populated from OpenVINO (falling back to the pdev) so callers can "name" populated from OpenVINO (falling back to the pdev) so callers can
surface a real device name in the UI. surface a real device name in the UI.
A device that exists but has no attached DRM clients reports an idle 0%
reading. None is returned only for durable failures (no Intel GPU, a bad
intel_gpu_device config, unreadable /proc, or a kernel that publishes no
counters), each of which logs a distinct warning, and the caller latches
it against retries for an hour.
""" """
from frigate.stats.intel_gpu_info import intel_gpu_name_resolver from frigate.stats.intel_gpu_info import intel_gpu_name_resolver
target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device) target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device)
if intel_gpu_device and not target_pdev:
logger.warning(
"Unable to collect Intel GPU stats: configured intel_gpu_device %s "
"does not exist or could not be resolved to a PCI device",
intel_gpu_device,
)
return None
drm_devices = _enumerate_drm_devices()
intel_pdevs = {
pdev: driver
for pdev, driver in drm_devices.items()
if driver in _INTEL_DRM_DRIVERS
}
if not intel_pdevs:
logger.warning(
"Unable to collect Intel GPU stats: no Intel GPU (i915/xe) found in "
"/sys/class/drm. Check that the driver is loaded on the host"
)
return None
if target_pdev and target_pdev not in intel_pdevs:
logger.warning(
"Unable to collect Intel GPU stats: configured intel_gpu_device %s "
"resolved to %s (driver: %s), which is not an Intel GPU",
intel_gpu_device,
target_pdev,
drm_devices.get(target_pdev, "unknown"),
)
return None
snapshot_a = _read_intel_drm_fdinfo(target_pdev) snapshot_a = _read_intel_drm_fdinfo(target_pdev)
if snapshot_a is None:
logger.warning("Unable to collect Intel GPU stats: /proc could not be read")
return None
if not snapshot_a: if not snapshot_a:
# No process currently holds the GPU open, e.g. while camera processes
# are restarting. The device is confirmed present, so report idle
# rather than an error; the next stats cycle re-samples normally.
logger.debug("No active DRM clients for Intel GPU, reporting idle")
return _idle_intel_gpu_stats(target_pdev, intel_pdevs)
if not any(client["engines"] for client in snapshot_a.values()):
# Clients exist but the kernel published no busyness for them, so
# there is nothing to sample and a second snapshot would not help.
# i915 suppresses per-client engine counters while GuC submission is
# active on kernels older than 6.5 (kernel commit 1324680a80eb lifted
# this), which covers stock Debian 12 and Ubuntu 22.04 on 12th gen
# and newer.
logger.warning( logger.warning(
"Unable to collect Intel GPU stats: no DRM fdinfo entries found" "Unable to collect Intel GPU stats: found %d DRM client(s) for %s but "
"%s. Check that /proc is readable and the i915/xe driver is loaded", "no per-engine counters. Kernel 6.5 or newer is required.",
f" for pdev {target_pdev}" if target_pdev else "", len(snapshot_a),
"/".join(sorted({client["driver"] for client in snapshot_a.values()})),
) )
return None return None
@@ -443,12 +571,18 @@ def get_intel_gpu_stats(
elapsed_ns = (time.monotonic() - start) * 1e9 elapsed_ns = (time.monotonic() - start) * 1e9
snapshot_b = _read_intel_drm_fdinfo(target_pdev) snapshot_b = _read_intel_drm_fdinfo(target_pdev)
if not snapshot_b or elapsed_ns <= 0: if snapshot_b is None:
logger.warning( logger.warning("Unable to collect Intel GPU stats: /proc could not be read")
"Unable to collect Intel GPU stats: second DRM fdinfo sample was empty"
)
return None return None
if not snapshot_b or elapsed_ns <= 0:
# Every client disappeared during the sample window; transient by
# definition, so report idle instead of latching an error.
logger.debug(
"No DRM clients persisted across Intel GPU samples, reporting idle"
)
return _idle_intel_gpu_stats(target_pdev, intel_pdevs)
def _new_engine_pct() -> dict[str, float]: def _new_engine_pct() -> dict[str, float]:
return {"render": 0.0, "video": 0.0, "video-enhance": 0.0, "compute": 0.0} return {"render": 0.0, "video": 0.0, "video-enhance": 0.0, "compute": 0.0}
@@ -460,6 +594,11 @@ def get_intel_gpu_stats(
if not data_a or data_a["driver"] != data_b["driver"]: if not data_a or data_a["driver"] != data_b["driver"]:
continue continue
# Skip before the setdefault below so a counter-less client cannot
# register its pdev on its own.
if not data_b["engines"]:
continue
pdev = key[0] pdev = key[0]
engine_pct = per_pdev_engine_pct.setdefault(pdev, _new_engine_pct()) engine_pct = per_pdev_engine_pct.setdefault(pdev, _new_engine_pct())
pid_pct = per_pdev_pid_pct.setdefault(pdev, {}) pid_pct = per_pdev_pid_pct.setdefault(pdev, {})
@@ -491,11 +630,12 @@ def get_intel_gpu_stats(
pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total
if not per_pdev_engine_pct: if not per_pdev_engine_pct:
logger.warning( # Clients were seen in both snapshots but none persisted as the same
"Unable to collect Intel GPU stats: no per-engine counters available " # (pdev, client-id, pid); process churn, so report idle.
"(i915 requires kernel >= 5.19)" logger.debug(
"No DRM clients persisted across Intel GPU samples, reporting idle"
) )
return None return _idle_intel_gpu_stats(target_pdev, intel_pdevs)
names = intel_gpu_name_resolver.get_names() names = intel_gpu_name_resolver.get_names()
results: dict[str, dict[str, Any]] = {} results: dict[str, dict[str, Any]] = {}
@@ -0,0 +1,129 @@
/**
* Semantic Search settings tests -- MEDIUM tier.
*
* Focuses on the model_size field, which is unused when a GenAI embeddings
* provider is selected as the semantic search model. The resolved config always
* reports model_size (it has a schema default of "small"), even when the YAML
* file has no such key. Clearing model_size for a provider used to run
* unconditionally, which falsely marked the section dirty on load and asked the
* backend to delete a key that wasn't in the config file (KeyError: 'model_size').
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const PROVIDER = "llama_cpp";
const SETTINGS_URL = "/settings?page=integrationSemanticSearch";
const NOT_APPLICABLE = "Not applicable for GenAI providers";
const UNSAVED = "You have unsaved changes";
type SemanticSearch = {
enabled?: boolean;
model?: string;
model_size?: string;
};
async function installRoutes(page: Page, semanticSearch: SemanticSearch) {
const config = configFactory({
genai: { [PROVIDER]: { provider: PROVIDER, roles: ["embeddings"] } },
semantic_search: semanticSearch,
});
let lastSavedConfig: unknown = null;
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({ json: config });
}
return route.fulfill({ json: { success: true } });
});
await page.route("**/api/config/set", async (route) => {
lastSavedConfig = route.request().postDataJSON();
await route.fulfill({ json: { success: true, require_restart: false } });
});
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: { semantic_search: semanticSearch } }),
);
return { capturedConfig: () => lastSavedConfig };
}
test.describe("semantic search model_size @medium", () => {
test("a provider with a defaulted model_size is not dirty on load", async ({
frigateApp,
}) => {
// model_size stays at its schema default ("small"), i.e. it is not present
// in the YAML. This mirrors the reported bug: selecting a GenAI provider and
// returning to the page.
await installRoutes(frigateApp.page, {
enabled: true,
model: PROVIDER,
});
await frigateApp.goto(SETTINGS_URL);
// The provider path is active: model_size shows "Not applicable".
await expect(frigateApp.page.getByText(NOT_APPLICABLE)).toBeVisible();
// Give any clearing effect time to fire, then confirm the section stayed
// clean (no phantom unsaved-changes banner, Save disabled).
await frigateApp.page.waitForTimeout(1000);
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
await expect(
frigateApp.page.getByRole("button", { name: "Save", exact: true }),
).toBeDisabled();
});
test("switching from a configured non-default model_size clears it", async ({
frigateApp,
}) => {
// A genuinely configured non-default model_size ("large") can only come from
// the YAML, so switching to a provider must still remove it.
const capture = await installRoutes(frigateApp.page, {
enabled: true,
model: "jinav2",
model_size: "large",
});
await frigateApp.goto(SETTINGS_URL);
// Starts clean on a Jina model.
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
// Switch the model to the GenAI provider.
await frigateApp.page
.getByRole("combobox", { name: /Semantic search model/ })
.click();
await frigateApp.page.getByRole("option", { name: PROVIDER }).click();
// The change is now dirty and model_size is no longer applicable.
await expect(frigateApp.page.getByText(NOT_APPLICABLE)).toBeVisible();
await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible();
await frigateApp.page
.getByRole("button", { name: "Save", exact: true })
.click();
// The saved payload removes model_size (empty string = "remove" key).
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
semantic_search: { model: PROVIDER, model_size: "" },
},
});
});
});
+2 -2
View File
@@ -322,7 +322,7 @@
}, },
"model_type": { "model_type": {
"label": "Object Detection Model Type", "label": "Object Detection Model Type",
"description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." "description": "Detector model architecture type (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine) used by some detectors for optimization."
} }
}, },
"model_path": { "model_path": {
@@ -495,7 +495,7 @@
}, },
"model_type": { "model_type": {
"label": "Object Detection Model Type", "label": "Object Detection Model Type",
"description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." "description": "Detector model architecture type (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine) used by some detectors for optimization."
} }
}, },
"genai": { "genai": {
+2 -1
View File
@@ -1936,7 +1936,8 @@
"inputDimensionsNotDetectResolution": "Model input width and height are the input dimensions of the object detection model, not your camera's detect resolution. They should match the dimensions of the model you're using — typically a square size like 320x320 or 640x640." "inputDimensionsNotDetectResolution": "Model input width and height are the input dimensions of the object detection model, not your camera's detect resolution. They should match the dimensions of the model you're using — typically a square size like 320x320 or 640x640."
}, },
"ffmpeg": { "ffmpeg": {
"hwaccelManualNotRecommended": "Manual hardware acceleration arguments are not recommended. Unless a specific requirement exists, select the preset that matches your hardware." "hwaccelManualNotRecommended": "Manual hardware acceleration arguments are not recommended. Unless a specific requirement exists, select the preset that matches your hardware.",
"inputsMissingGo2rtcStream": "An input below points at a go2rtc restream that no longer exists. Select an existing restream or enter the camera's URL manually, otherwise this camera will fail to connect."
}, },
"objects": { "objects": {
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated." "genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
+1 -6
View File
@@ -107,12 +107,7 @@
}, },
"npuUsage": "NPU Usage", "npuUsage": "NPU Usage",
"npuMemory": "NPU Memory", "npuMemory": "NPU Memory",
"npuTemperature": "NPU Temperature", "npuTemperature": "NPU Temperature"
"intelGpuWarning": {
"title": "Intel GPU Stats Warning",
"message": "GPU stats unavailable",
"description": "This is a known bug in Intel's GPU stats reporting tools (intel_gpu_top) where it will break and repeatedly return a GPU usage of 0% even in cases where hardware acceleration and object detection are correctly running on the (i)GPU. This is not a Frigate bug. You can restart the host to temporarily fix the issue and confirm that the GPU is working correctly. This does not affect performance."
}
}, },
"otherProcesses": { "otherProcesses": {
"title": "Other Processes", "title": "Other Processes",
@@ -1,3 +1,4 @@
import { parseRestreamStreamName } from "../theme/fields/streamSource";
import type { SectionConfigOverrides } from "./types"; import type { SectionConfigOverrides } from "./types";
const arrayAsTextWidget = { const arrayAsTextWidget = {
@@ -42,6 +43,29 @@ const ffmpeg: SectionConfigOverrides = {
return false; return false;
}, },
}, },
{
key: "inputs-missing-go2rtc-stream",
field: "inputs",
position: "before",
messageKey: "configMessages.ffmpeg.inputsMissingGo2rtcStream",
severity: "warning",
docLink: "/configuration/restream",
condition: (ctx) => {
const streams = ctx.fullConfig?.go2rtc?.streams;
const inputs = ctx.formData?.inputs;
if (!Array.isArray(inputs)) {
return false;
}
return inputs.some((input) => {
const path = (input as { path?: unknown } | null)?.path;
const streamName = parseRestreamStreamName(
typeof path === "string" ? path : undefined,
);
return streamName !== undefined && !(streamName in (streams ?? {}));
});
},
},
], ],
fieldDocs: { fieldDocs: {
hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets", hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets",
@@ -91,6 +91,7 @@ export default function BirdseyeCameraReorder({
try { try {
await axios.put("config/set", { await axios.put("config/set", {
requires_restart: 0, requires_restart: 0,
update_topic: "config/cameras/*/birdseye",
config_data: { cameras: cameraUpdates }, config_data: { cameras: cameraUpdates },
}); });
await updateConfig(); await updateConfig();
@@ -170,6 +170,12 @@ export function CameraInputsField(props: FieldProps) {
[go2rtcStreamNames], [go2rtcStreamNames],
); );
useEffect(() => {
setSourceModeByIndex((previous) =>
Object.keys(previous).length > 0 ? {} : previous,
);
}, [formContext?.cameraName]);
useEffect(() => { useEffect(() => {
setOpenByIndex((previous) => { setOpenByIndex((previous) => {
const next: Record<number, boolean> = {}; const next: Record<number, boolean> = {};
@@ -222,18 +228,12 @@ export function CameraInputsField(props: FieldProps) {
const handleSourceModeChange = useCallback( const handleSourceModeChange = useCallback(
(index: number, nextMode: StreamSourceMode) => { (index: number, nextMode: StreamSourceMode) => {
const input = inputs[index]; const input = inputs[index];
const currentPath =
typeof input?.path === "string" ? input.path : undefined;
if (nextMode === "manual") { // Only revert the preset we set ourselves; never clobber custom args.
// Only revert the preset we set ourselves; never clobber custom args. // The path is left alone until a stream is picked, so switching modes
if (input?.input_args === RESTREAM_PRESET) { // never discards a typed URL or empties a required field.
handleFieldValuesChange(index, { input_args: undefined }); if (nextMode === "manual" && input?.input_args === RESTREAM_PRESET) {
} handleFieldValuesChange(index, { input_args: undefined });
} else if (!parseRestreamStreamName(currentPath)) {
// Entering restream with a non-restream path: clear it so the dropdown
// shows its placeholder until a stream is chosen.
handleFieldValuesChange(index, { path: undefined });
} }
setSourceModeByIndex((previous) => ({ ...previous, [index]: nextMode })); setSourceModeByIndex((previous) => ({ ...previous, [index]: nextMode }));
@@ -160,6 +160,7 @@ export function GenAIModelWidget(props: WidgetProps) {
try { try {
const res = await axios.post<ProbeResponse>("genai/probe", { const res = await axios.post<ProbeResponse>("genai/probe", {
provider: formProvider, provider: formProvider,
name: providerKey,
api_key: api_key:
typeof formEntry.api_key === "string" ? formEntry.api_key : null, typeof formEntry.api_key === "string" ? formEntry.api_key : null,
base_url: base_url:
@@ -227,16 +228,18 @@ export function GenAIModelWidget(props: WidgetProps) {
aria-expanded={open} aria-expanded={open}
disabled={disabled || readonly} disabled={disabled || readonly}
className={cn( className={cn(
"justify-between font-normal", "min-w-0 justify-between font-normal",
!currentLabel && "text-muted-foreground", !currentLabel && "text-muted-foreground",
fieldClassName, fieldClassName,
)} )}
> >
{currentLabel ?? <span className="truncate">
t("configForm.genaiModel.placeholder", { {currentLabel ??
ns: "views/settings", t("configForm.genaiModel.placeholder", {
defaultValue: "Select or enter a model…", ns: "views/settings",
})} defaultValue: "Select or enter a model…",
})}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -263,12 +266,14 @@ export function GenAIModelWidget(props: WidgetProps) {
value={trimmedSearch} value={trimmedSearch}
onSelect={() => commit(trimmedSearch)} onSelect={() => commit(trimmedSearch)}
> >
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4 shrink-0" />
{t("configForm.genaiModel.useCustom", { <span className="truncate">
ns: "views/settings", {t("configForm.genaiModel.useCustom", {
value: trimmedSearch, ns: "views/settings",
defaultValue: 'Use "{{value}}"', value: trimmedSearch,
})} defaultValue: 'Use "{{value}}"',
})}
</span>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
)} )}
@@ -287,11 +292,11 @@ export function GenAIModelWidget(props: WidgetProps) {
> >
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4 shrink-0",
value === model ? "opacity-100" : "opacity-0", value === model ? "opacity-100" : "opacity-0",
)} )}
/> />
{model} <span className="truncate">{model}</span>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
@@ -24,15 +24,19 @@ export function SemanticSearchModelSizeWidget(props: WidgetProps) {
model !== "jinav1" && model !== "jinav1" &&
model !== "jinav2"; model !== "jinav2";
// Clear model_size while on a provider (buildOverrides converts to "" // model_size is unused on a GenAI provider. Only clear it (which the backend
// which the backend treats as "remove"). Restore the schema default // treats as "remove") for a non-default value, which can only come from the
// when returning to a Jina model so the field isn't left empty. // config file. A defaulted value is indistinguishable from unset in the
// resolved config, so clearing it would falsely dirty the field and delete a
// YAML key that isn't there. Restore the default when returning to a Jina model.
const { value, onChange, schema } = props; const { value, onChange, schema } = props;
const schemaDefault = schema?.default as string | undefined; const schemaDefault = schema?.default as string | undefined;
useEffect(() => { useEffect(() => {
if (isProvider && value !== undefined) { if (isProvider) {
onChange(undefined); if (value !== undefined && value !== schemaDefault) {
} else if (!isProvider && value === undefined && schemaDefault) { onChange(undefined);
}
} else if (value === undefined && schemaDefault) {
onChange(schemaDefault); onChange(schemaDefault);
} }
}, [isProvider, value, onChange, schemaDefault]); }, [isProvider, value, onChange, schemaDefault]);
@@ -129,7 +129,7 @@ export function GeneralFilterContent({
className="mx-2 w-full cursor-pointer text-primary smart-capitalize" className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
htmlFor={item} htmlFor={item}
> >
{item.replaceAll("_", " ")} {t(`logger.logLevel.${item}`, { ns: "views/settings" })}
</Label> </Label>
<Switch <Switch
key={item} key={item}
+5 -1
View File
@@ -153,11 +153,15 @@ export default function IconPicker({
} }
type IconRendererProps = { type IconRendererProps = {
icon: IconType; icon: IconType | undefined;
size?: number; size?: number;
className?: string; className?: string;
}; };
export function IconRenderer({ icon, size, className }: IconRendererProps) { export function IconRenderer({ icon, size, className }: IconRendererProps) {
if (!icon) {
return null;
}
return <>{React.createElement(icon, { size, className })}</>; return <>{React.createElement(icon, { size, className })}</>;
} }
+3 -1
View File
@@ -3,6 +3,7 @@ import { LogSeverity } from "@/types/log";
import { ReactNode, useMemo } from "react"; import { ReactNode, useMemo } from "react";
import { isIOS } from "react-device-detect"; import { isIOS } from "react-device-detect";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useTranslation } from "react-i18next";
type ChipProps = { type ChipProps = {
className?: string; className?: string;
@@ -50,6 +51,7 @@ type LogChipProps = {
onClickSeverity?: () => void; onClickSeverity?: () => void;
}; };
export function LogChip({ severity, onClickSeverity }: LogChipProps) { export function LogChip({ severity, onClickSeverity }: LogChipProps) {
const { t } = useTranslation(["views/settings"]);
const severityClassName = useMemo(() => { const severityClassName = useMemo(() => {
switch (severity) { switch (severity) {
case "info": case "info":
@@ -73,7 +75,7 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
} }
}} }}
> >
{severity} {t(`logger.logLevel.${severity}`, { ns: "views/settings" })}
</span> </span>
</div> </div>
); );
+22 -5
View File
@@ -65,6 +65,7 @@ import { Textarea } from "../ui/textarea";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { isReplayCamera } from "@/utils/cameraUtil"; import { isReplayCamera } from "@/utils/cameraUtil";
import { isValidIconName } from "@/utils/iconUtil";
const EXPORT_OPTIONS = [ const EXPORT_OPTIONS = [
"1", "1",
@@ -443,10 +444,10 @@ export function ExportContent({
} }
setRange({ setRange({
before: latestTime, before: currentTime + 1800,
after: latestTime - 3600, after: currentTime - 1800,
}); });
}, [activeTab, latestTime, range, setRange]); }, [activeTab, currentTime, range, setRange]);
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>( const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
activeTab === "multi" && debouncedRange activeTab === "multi" && debouncedRange
@@ -816,7 +817,19 @@ export function ExportContent({
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={(value) => setActiveTab(value as ExportTab)} onValueChange={(value) => {
const tab = value as ExportTab;
if (tab === "multi") {
setRange({
before: currentTime + 1800,
after: currentTime - 1800,
});
} else {
onSelectTime(selectedOption);
}
setActiveTab(tab);
}}
className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")} className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")}
> >
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
@@ -1066,7 +1079,11 @@ export function ExportContent({
} }
> >
<IconRenderer <IconRenderer
icon={LuIcons[group.icon]} icon={
isValidIconName(group.icon)
? LuIcons[group.icon]
: LuIcons.LuFolder
}
className="mr-2 size-4 text-secondary-foreground" className="mr-2 size-4 text-secondary-foreground"
/> />
<span className="truncate">{group.name}</span> <span className="truncate">{group.name}</span>
+4 -1
View File
@@ -60,7 +60,10 @@ const CommandList = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive.List <CommandPrimitive.List
ref={ref} ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} className={cn(
"scrollbar-container max-h-[300px] overflow-y-auto overflow-x-hidden",
className,
)}
{...props} {...props}
/> />
)); ));
@@ -770,6 +770,34 @@ export default function MotionSearchView({
}; };
}, [cancelMotionSearchJobViaBeacon]); }, [cancelMotionSearchJobViaBeacon]);
const handleBack = useCallback(() => {
if (onBack) {
onBack();
} else {
navigate(-1);
}
}, [navigate, onBack]);
// Dismissing the entry dialog (escape / click outside) before a search has
// run leaves nothing behind it, so cancel the flow instead of revealing an
// empty page.
const handleSearchDialogOpenChange = useCallback(
(nextOpen: boolean) => {
if (
!nextOpen &&
!isSearching &&
!hasSearched &&
searchResults.length === 0
) {
handleBack();
return;
}
setIsSearchDialogOpen(nextOpen);
},
[handleBack, hasSearched, isSearching, searchResults.length],
);
const handleNewSearch = useCallback(() => { const handleNewSearch = useCallback(() => {
if (jobId && jobCamera) { if (jobId && jobCamera) {
void cancelMotionSearchJob(jobId, jobCamera); void cancelMotionSearchJob(jobId, jobCamera);
@@ -1238,7 +1266,7 @@ export default function MotionSearchView({
<Toaster closeButton={true} position="top-center" /> <Toaster closeButton={true} position="top-center" />
<MotionSearchDialog <MotionSearchDialog
open={isSearchDialogOpen} open={isSearchDialogOpen}
onOpenChange={setIsSearchDialogOpen} onOpenChange={handleSearchDialogOpenChange}
config={config} config={config}
cameras={cameras} cameras={cameras}
selectedCamera={selectedCamera} selectedCamera={selectedCamera}
@@ -1276,7 +1304,7 @@ export default function MotionSearchView({
className="flex items-center gap-2.5 rounded-lg" className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })} aria-label={t("label.back", { ns: "common" })}
size="sm" size="sm"
onClick={() => (onBack ? onBack() : navigate(-1))} onClick={handleBack}
> >
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" /> <IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && ( {isDesktop && (
+1 -83
View File
@@ -470,50 +470,6 @@ export default function GeneralMetrics({
return Object.keys(series).length > 0 ? Object.values(series) : undefined; return Object.keys(series).length > 0 ? Object.values(series) : undefined;
}, [statsHistory]); }, [statsHistory]);
// Check if Intel GPU has all 0% usage values (known bug)
const showIntelGpuWarning = useMemo(() => {
if (!statsHistory || statsHistory.length < 3) {
return false;
}
const hasIntelGpu = Object.values(statsHistory[0]?.gpu_usages ?? {}).some(
(stats) => stats.vendor === "intel",
);
if (!hasIntelGpu) {
return false;
}
// Check if all GPU usage values are 0% across all stats
let allZero = true;
let hasDataPoints = false;
for (const stats of statsHistory) {
if (!stats) {
continue;
}
Object.values(stats.gpu_usages || {}).forEach((gpuStats) => {
if (gpuStats.vendor !== "intel") {
return;
}
if (gpuStats.gpu) {
hasDataPoints = true;
const gpuValue = parseFloat(gpuStats.gpu.slice(0, -1));
if (!isNaN(gpuValue) && gpuValue > 0) {
allZero = false;
}
}
});
if (!allZero) {
break;
}
}
return hasDataPoints && allZero;
}, [statsHistory]);
// npu stats // npu stats
const npuSeries = useMemo(() => { const npuSeries = useMemo(() => {
@@ -819,46 +775,8 @@ export default function GeneralMetrics({
<> <>
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl"> <div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5 flex flex-row items-center justify-between"> <div className="mb-5">
{t("general.hardwareInfo.gpuUsage")} {t("general.hardwareInfo.gpuUsage")}
{showIntelGpuWarning && (
<Popover>
<PopoverTrigger asChild>
<button
className="flex flex-row items-center gap-1.5 text-yellow-600 focus:outline-none dark:text-yellow-500"
aria-label={t(
"general.hardwareInfo.intelGpuWarning.title",
)}
>
<CiCircleAlert
className="size-5"
aria-label={t(
"general.hardwareInfo.intelGpuWarning.title",
)}
/>
<span className="text-sm">
{t(
"general.hardwareInfo.intelGpuWarning.message",
)}
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
<div className="font-semibold">
{t(
"general.hardwareInfo.intelGpuWarning.title",
)}
</div>
<div>
{t(
"general.hardwareInfo.intelGpuWarning.description",
)}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div> </div>
{gpuSeries.map((series) => ( {gpuSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph