mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-22 03:39:02 +03:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d583a009b5 | ||
|
|
5f966de322 | ||
|
|
a4f2880f38 | ||
|
|
e847f3092d | ||
|
|
3ff2d1e727 | ||
|
|
48425cc82f | ||
|
|
c0cf08ab4a | ||
|
|
6f80bcd19f | ||
|
|
d02a1156b7 | ||
|
|
c17538aff9 | ||
|
|
dd7e9f1bc5 | ||
|
|
48aaafba3c | ||
|
|
f1028d0c36 | ||
|
|
70d629bf93 | ||
|
|
c406a93d3d |
@@ -12,6 +12,7 @@ config/*
|
||||
models
|
||||
*.mp4
|
||||
*.db
|
||||
*.db-*
|
||||
*.csv
|
||||
frigate/version.py
|
||||
web/build
|
||||
|
||||
@@ -81,10 +81,10 @@ RUN --mount=type=bind,source=docker/main/install_tempio.sh,target=/deps/install_
|
||||
FROM base_host AS ov-converter
|
||||
ARG DEBIAN_FRONTEND
|
||||
|
||||
# Install OpenVino Runtime and Dev library
|
||||
# Install OpenVINO for model conversion
|
||||
COPY docker/main/requirements-ov.txt /requirements-ov.txt
|
||||
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 \
|
||||
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
|
||||
&& python3 get-pip.py "pip" \
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
import openvino as ov
|
||||
from openvino.tools import mo
|
||||
"""Convert the default SSDLite MobileNet v2 model to OpenVINO IR.
|
||||
|
||||
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",
|
||||
compress_to_fp16=True,
|
||||
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,
|
||||
input=[("image_tensor:0", [1, 300, 300, 3])],
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
SQLITE_VEC_VERSION="0.1.3"
|
||||
SQLITE_VEC_VERSION="0.1.9"
|
||||
|
||||
source /etc/os-release
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
numpy
|
||||
tensorflow
|
||||
openvino-dev>=2024.0.0
|
||||
openvino >= 2026.2.0
|
||||
|
||||
@@ -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
|
||||
mqtt:
|
||||
# Optional: Enable mqtt server (default: shown below)
|
||||
@@ -171,13 +173,14 @@ model:
|
||||
# Valid values are rgb, bgr, or yuv. (default: shown below)
|
||||
input_pixel_format: rgb
|
||||
# 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
|
||||
# Optional: Data type of the model input tensor
|
||||
# Valid values are float, float_denorm, or int (default: shown below)
|
||||
input_dtype: int
|
||||
# Required: Object detection model type, currently only used with the OpenVINO detector
|
||||
# Valid values are ssd, yolox, yolonas (default: shown below)
|
||||
# Required: Object detection model architecture, used by detectors that support more
|
||||
# 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
|
||||
# Required: Label name modifications. These are merged into the standard labelmap.
|
||||
labelmap:
|
||||
@@ -468,8 +471,8 @@ review:
|
||||
detections: False
|
||||
# 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.
|
||||
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)
|
||||
# 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.
|
||||
@@ -813,7 +816,8 @@ classification:
|
||||
cameras:
|
||||
camera_name:
|
||||
# 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)
|
||||
motion: False
|
||||
# Optional: Interval to run classification on in seconds (default: shown below)
|
||||
|
||||
@@ -335,7 +335,7 @@ For example:
|
||||
```
|
||||
services:
|
||||
frigate:
|
||||
image: blakeblackshear/frigate:latest
|
||||
image: ghcr.io/blakeblackshear/frigate:stable
|
||||
environment:
|
||||
- FRIGATE_BASE_PATH=/frigate
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
:::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
|
||||
|
||||
**Authenticated Port (8971)**
|
||||
|
||||
@@ -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.
|
||||
- **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:
|
||||
|
||||
@@ -130,7 +130,8 @@ go2rtc:
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
api_key: "{FRIGATE_GENAI_API_KEY}"
|
||||
my_provider:
|
||||
api_key: "{FRIGATE_GENAI_API_KEY}"
|
||||
```
|
||||
|
||||
## Common configuration examples
|
||||
|
||||
@@ -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)
|
||||
cameras:
|
||||
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.
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
- 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.
|
||||
- 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.
|
||||
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
|
||||
|
||||
|
||||
@@ -9,9 +9,27 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
## 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
|
||||
|
||||
@@ -78,23 +96,26 @@ All llama.cpp native options can be passed through `provider_options`, including
|
||||
- Set **Provider** to `llamacpp`
|
||||
- Set **Base URL** to your llama.cpp server address (e.g., `http://localhost:8080`)
|
||||
- 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 value="yaml">
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: llamacpp
|
||||
base_url: http://localhost:8080
|
||||
model: your-model-name
|
||||
provider_options:
|
||||
context_size: 16000 # Tell Frigate your context size so it can send the appropriate amount of information.
|
||||
my_provider:
|
||||
provider: llamacpp
|
||||
base_url: http://localhost:8080
|
||||
model: your-model-name
|
||||
provider_options:
|
||||
context_size: 16000 # Optional, overrides the context size reported by the server.
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</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](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
|
||||
genai:
|
||||
provider: ollama
|
||||
base_url: http://localhost:11434
|
||||
model: qwen3-vl:4b
|
||||
provider_options: # other Ollama client options can be defined
|
||||
keep_alive: -1
|
||||
options:
|
||||
num_ctx: 8192 # make sure the context matches other services that are using ollama
|
||||
my_provider:
|
||||
provider: ollama
|
||||
base_url: http://localhost:11434
|
||||
model: qwen3-vl:4b
|
||||
provider_options: # other Ollama client options can be defined
|
||||
keep_alive: -1
|
||||
options:
|
||||
num_ctx: 8192 # make sure the context matches other services that are using ollama
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
@@ -149,11 +171,12 @@ For OpenAI-compatible servers (such as llama.cpp) that don't expose the configur
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: openai
|
||||
base_url: http://your-llama-server
|
||||
model: your-model-name
|
||||
provider_options:
|
||||
context_size: 8192 # Specify the configured context size
|
||||
my_provider:
|
||||
provider: openai
|
||||
base_url: http://your-llama-server
|
||||
model: your-model-name
|
||||
provider_options:
|
||||
context_size: 8192 # Specify the configured context size
|
||||
```
|
||||
|
||||
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
|
||||
genai:
|
||||
provider: openai
|
||||
base_url: http://your-server:port
|
||||
api_key: your-api-key # May not be required for local servers
|
||||
model: your-model-name
|
||||
my_provider:
|
||||
provider: openai
|
||||
base_url: http://your-server:port
|
||||
api_key: your-api-key # May not be required for local servers
|
||||
model: your-model-name
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
@@ -217,19 +241,21 @@ Ollama also supports [cloud models](https://ollama.com/cloud), where model infer
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: ollama
|
||||
base_url: http://localhost:11434
|
||||
model: cloud-model-name
|
||||
my_provider:
|
||||
provider: ollama
|
||||
base_url: http://localhost:11434
|
||||
model: cloud-model-name
|
||||
```
|
||||
|
||||
or when using Ollama Cloud directly
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: ollama
|
||||
base_url: https://ollama.com
|
||||
model: cloud-model-name
|
||||
api_key: your-api-key
|
||||
my_provider:
|
||||
provider: ollama
|
||||
base_url: https://ollama.com
|
||||
model: cloud-model-name
|
||||
api_key: your-api-key
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
@@ -267,9 +293,10 @@ To start using Gemini, you must first get an API key from [Google AI Studio](htt
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: gemini
|
||||
api_key: "{FRIGATE_GEMINI_API_KEY}"
|
||||
model: gemini-2.5-flash
|
||||
my_provider:
|
||||
provider: gemini
|
||||
api_key: "{FRIGATE_GEMINI_API_KEY}"
|
||||
model: gemini-2.5-flash
|
||||
```
|
||||
|
||||
</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:
|
||||
|
||||
```yaml {4,5}
|
||||
```yaml {5,6}
|
||||
genai:
|
||||
provider: gemini
|
||||
...
|
||||
provider_options:
|
||||
base_url: https://...
|
||||
my_provider:
|
||||
provider: gemini
|
||||
...
|
||||
provider_options:
|
||||
base_url: https://...
|
||||
```
|
||||
|
||||
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
|
||||
genai:
|
||||
provider: openai
|
||||
api_key: "{FRIGATE_OPENAI_API_KEY}"
|
||||
model: gpt-4o
|
||||
my_provider:
|
||||
provider: openai
|
||||
api_key: "{FRIGATE_OPENAI_API_KEY}"
|
||||
model: gpt-4o
|
||||
```
|
||||
|
||||
</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`:
|
||||
|
||||
```yaml {5,6}
|
||||
```yaml {6,7}
|
||||
genai:
|
||||
provider: openai
|
||||
base_url: http://your-llama-server
|
||||
model: your-model-name
|
||||
provider_options:
|
||||
context_size: 8192 # Specify the configured context size
|
||||
my_provider:
|
||||
provider: openai
|
||||
base_url: http://your-llama-server
|
||||
model: your-model-name
|
||||
provider_options:
|
||||
context_size: 8192 # Specify the configured context size
|
||||
```
|
||||
|
||||
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
|
||||
genai:
|
||||
provider: azure_openai
|
||||
base_url: https://instance.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview
|
||||
model: gpt-5-mini
|
||||
api_key: "{FRIGATE_OPENAI_API_KEY}"
|
||||
my_provider:
|
||||
provider: azure_openai
|
||||
base_url: https://instance.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview
|
||||
model: gpt-5-mini
|
||||
api_key: "{FRIGATE_OPENAI_API_KEY}"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -52,9 +52,10 @@ You can define custom prompts at the global level and per-object type. To config
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: ollama
|
||||
base_url: http://localhost:11434
|
||||
model: qwen3-vl:8b-instruct
|
||||
my_provider:
|
||||
provider: ollama
|
||||
base_url: http://localhost:11434
|
||||
model: qwen3-vl:8b-instruct
|
||||
|
||||
objects:
|
||||
genai:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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?
|
||||
|
||||
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.
|
||||
|
||||
@@ -163,8 +163,8 @@ genai:
|
||||
model: your-model-name
|
||||
roles:
|
||||
- embeddings
|
||||
- vision
|
||||
- tools
|
||||
- descriptions
|
||||
- chat
|
||||
|
||||
semantic_search:
|
||||
enabled: True
|
||||
|
||||
@@ -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**.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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/>
|
||||
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="original" label="Example Docker Compose File">
|
||||
```yaml
|
||||
|
||||
@@ -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
|
||||
address that is derived from the default behavior described above. This option supports
|
||||
[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
|
||||
template, only the camera dict from Frigate.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ MQTT requires a network connection to your broker. This is typically local, but
|
||||
### `frigate/available`
|
||||
|
||||
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
|
||||
"offline": published automatically by the MQTT broker if Frigate disconnects unexpectedly (via MQTT Will Message)
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
---
|
||||
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. There is no automatic repair. If a `backup.db` exists next to your database (Frigate writes one before schema migrations), restoring it is the least destructive option. Otherwise, stop Frigate, delete `frigate.db`, and restart: Frigate recreates it, but existing recordings lose their metadata (events, review items). Moving the database off a network share prevents recurrence.
|
||||
|
||||
</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>
|
||||
@@ -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.
|
||||
|
||||
</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>
|
||||
|
||||
@@ -34,7 +34,7 @@ All of your exports live on the **Exports** page, reachable from the main naviga
|
||||
- **Rename** it, and
|
||||
- **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
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const sidebars: SidebarsConfig = {
|
||||
],
|
||||
Configuration: [
|
||||
"configuration/config",
|
||||
"configuration/config_overrides",
|
||||
{
|
||||
type: "category",
|
||||
label: "Detectors",
|
||||
@@ -165,6 +166,7 @@ const sidebars: SidebarsConfig = {
|
||||
],
|
||||
Troubleshooting: [
|
||||
"troubleshooting/faqs",
|
||||
"troubleshooting/common_errors",
|
||||
"troubleshooting/go2rtc",
|
||||
"troubleshooting/recordings",
|
||||
"troubleshooting/dummy-camera",
|
||||
|
||||
Vendored
+5
@@ -8244,6 +8244,11 @@ components:
|
||||
properties:
|
||||
provider:
|
||||
$ref: '#/components/schemas/GenAIProviderEnum'
|
||||
name:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Name
|
||||
api_key:
|
||||
anyOf:
|
||||
- type: string
|
||||
|
||||
+16
-16
@@ -31,6 +31,7 @@ from frigate.api.auth import (
|
||||
get_allowed_cameras_for_filter,
|
||||
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.request.app_body import (
|
||||
AppConfigSetBody,
|
||||
@@ -195,7 +196,7 @@ def genai_models(request: Request):
|
||||
"before saving the configuration."
|
||||
),
|
||||
)
|
||||
async def genai_probe(body: GenAIProbeBody):
|
||||
async def genai_probe(request: Request, body: GenAIProbeBody):
|
||||
load_providers()
|
||||
|
||||
provider_cls = PROVIDERS.get(body.provider)
|
||||
@@ -205,6 +206,13 @@ async def genai_probe(body: GenAIProbeBody):
|
||||
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
|
||||
# provider_options; other plugins use GenAIClient.timeout passed below.
|
||||
# Don't inject timeout for Gemini — its HttpOptions interprets the value
|
||||
@@ -216,7 +224,7 @@ async def genai_probe(body: GenAIProbeBody):
|
||||
try:
|
||||
transient_cfg = GenAIConfig(
|
||||
provider=body.provider,
|
||||
api_key=body.api_key,
|
||||
api_key=api_key,
|
||||
base_url=body.base_url,
|
||||
provider_options=probe_provider_options,
|
||||
# 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:
|
||||
old_config: FrigateConfig = request.app.frigate_config
|
||||
request.app.frigate_config = 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
|
||||
swap_runtime_config(request.app, config)
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
@@ -971,7 +967,11 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
content=(
|
||||
{
|
||||
"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,
|
||||
|
||||
@@ -25,6 +25,7 @@ from frigate.api.auth import (
|
||||
require_go2rtc_stream_access,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.config_util import swap_runtime_config
|
||||
from frigate.api.defs.request.app_body import CameraSetBody
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config import FrigateConfig
|
||||
@@ -1254,9 +1255,14 @@ async def delete_camera(
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Update runtime config
|
||||
request.app.frigate_config = config
|
||||
request.app.genai_manager.update_config(config)
|
||||
# rebind every collaborator to the new config and re-layer runtime
|
||||
# toggles for the surviving cameras, same as /api/config/set
|
||||
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
|
||||
request.app.config_publisher.publish_update(
|
||||
|
||||
@@ -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()
|
||||
@@ -14,6 +14,7 @@ class AppConfigSetBody(BaseModel):
|
||||
|
||||
class GenAIProbeBody(BaseModel):
|
||||
provider: GenAIProviderEnum
|
||||
name: str | None = None
|
||||
api_key: str | None = None
|
||||
base_url: str | None = None
|
||||
provider_options: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
+15
-10
@@ -1538,15 +1538,18 @@ async def set_description(
|
||||
event.data["description"] = new_description
|
||||
event.save()
|
||||
|
||||
# If semantic search is enabled, update the index
|
||||
if request.app.frigate_config.semantic_search.enabled:
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
context: EmbeddingsContext | None = request.app.embeddings
|
||||
|
||||
if context is not None:
|
||||
if len(new_description) > 0:
|
||||
context.update_description(
|
||||
event_id,
|
||||
new_description,
|
||||
)
|
||||
# If semantic search is enabled, update the index
|
||||
if request.app.frigate_config.semantic_search.enabled:
|
||||
context.update_description(
|
||||
event_id,
|
||||
new_description,
|
||||
)
|
||||
else:
|
||||
# embeddings are always cleaned up so they don't outlive their description
|
||||
context.db.delete_embeddings_description(event_ids=[event_id])
|
||||
|
||||
response_message = (
|
||||
@@ -1675,9 +1678,11 @@ async def delete_single_event(event_id: str, request: Request) -> dict:
|
||||
event.delete_instance()
|
||||
Timeline.delete().where(Timeline.source_id == event_id).execute()
|
||||
|
||||
# If semantic search is enabled, update the index
|
||||
if request.app.frigate_config.semantic_search.enabled:
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
# embeddings are always cleaned up, even when semantic search is disabled,
|
||||
# so that they don't outlive their events
|
||||
context: EmbeddingsContext | None = request.app.embeddings
|
||||
|
||||
if context is not None:
|
||||
context.db.delete_embeddings_thumbnail(event_ids=[event_id])
|
||||
context.db.delete_embeddings_description(event_ids=[event_id])
|
||||
|
||||
|
||||
+1
-1
@@ -270,7 +270,7 @@ class FrigateApp:
|
||||
10
|
||||
* 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 = [
|
||||
Event,
|
||||
|
||||
@@ -117,7 +117,9 @@ class CameraMaintainer(threading.Thread):
|
||||
|
||||
if runtime:
|
||||
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(
|
||||
name,
|
||||
config.detect,
|
||||
|
||||
@@ -111,9 +111,9 @@ class CameraState:
|
||||
# draw thicker box around ptz autotracked object
|
||||
if (
|
||||
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
|
||||
]
|
||||
)
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
]
|
||||
|
||||
@@ -404,38 +404,64 @@ class Dispatcher:
|
||||
for comm in self.comms:
|
||||
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.
|
||||
|
||||
Called once after Frigate startup completes so processing threads can
|
||||
receive the resulting ``config_updater`` broadcasts. Unknown cameras
|
||||
and topics are skipped; handler exceptions are logged and replay
|
||||
continues for remaining entries.
|
||||
Routing through the handlers (rather than mutating config directly) is
|
||||
deliberate: they publish the ``config_updater`` broadcast and the
|
||||
retained MQTT state as a side effect, so worker processes and the UI
|
||||
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()
|
||||
applied: dict[str, dict[str, bool]] = {}
|
||||
|
||||
for camera_name, features in state.items():
|
||||
if camera_name not in self.config.cameras:
|
||||
continue
|
||||
|
||||
for topic, value in features.items():
|
||||
handler = self._camera_settings_handlers.get(topic)
|
||||
|
||||
if handler is None:
|
||||
continue
|
||||
|
||||
payload = "ON" if value else "OFF"
|
||||
|
||||
try:
|
||||
handler(camera_name, payload)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to restore runtime state %s.%s=%s",
|
||||
"Failed to apply runtime state %s.%s=%s",
|
||||
camera_name,
|
||||
topic,
|
||||
payload,
|
||||
)
|
||||
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(
|
||||
"Restored runtime state: %s.%s=%s",
|
||||
camera_name,
|
||||
topic,
|
||||
payload,
|
||||
"ON" if value else "OFF",
|
||||
)
|
||||
|
||||
def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
|
||||
@@ -458,6 +484,56 @@ class Dispatcher:
|
||||
"""
|
||||
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:
|
||||
"""Callback for detect topic."""
|
||||
detect_settings = self.config.cameras[camera_name].detect
|
||||
@@ -588,6 +664,10 @@ class Dispatcher:
|
||||
self.ptz_metrics[camera_name].start_time.value = 0
|
||||
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)
|
||||
|
||||
def _on_motion_contour_area_command(self, camera_name: str, payload: int) -> None:
|
||||
|
||||
@@ -96,6 +96,25 @@ class RuntimeStatePersistence:
|
||||
except OSError:
|
||||
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:
|
||||
"""Remove stored entries whose YAML key was just rewritten.
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ from frigate.const import (
|
||||
EXPIRE_AUDIO_ACTIVITY,
|
||||
INSERT_MANY_RECORDINGS,
|
||||
INSERT_PREVIEW,
|
||||
NOTIFICATION_TEST,
|
||||
REQUEST_REGION_GRID,
|
||||
UPDATE_AUDIO_ACTIVITY,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
@@ -57,7 +56,6 @@ _WS_BLOCKED_TOPICS = frozenset(
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
NOTIFICATION_TEST,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
add = "add" # for adding a camera
|
||||
audio = "audio"
|
||||
audio_transcription = "audio_transcription"
|
||||
autotracking = "autotracking" # ptz autotracking only, without an onvif reinit
|
||||
birdseye = "birdseye"
|
||||
detect = "detect"
|
||||
enabled = "enabled"
|
||||
@@ -145,6 +146,8 @@ class CameraConfigUpdateSubscriber:
|
||||
config.snapshots = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.onvif:
|
||||
config.onvif = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.autotracking:
|
||||
config.onvif.autotracking = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.timestamp_style:
|
||||
config.timestamp_style = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.zones:
|
||||
|
||||
@@ -640,7 +640,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
# set notifications state
|
||||
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] = {}
|
||||
for name, genai_cfg in self.genai.items():
|
||||
for role in genai_cfg.roles:
|
||||
|
||||
@@ -141,6 +141,11 @@ class ProfileManager:
|
||||
Preserves active profile state: re-snapshots base configs from the new
|
||||
(freshly parsed) config, then re-applies profile overrides if a profile
|
||||
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
|
||||
self.config = new_config
|
||||
@@ -164,10 +169,6 @@ class ProfileManager:
|
||||
self.config.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(
|
||||
self,
|
||||
profile_name: str | None,
|
||||
|
||||
@@ -288,6 +288,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
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)
|
||||
|
||||
if not res:
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
import regex
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REGEXP_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
|
||||
@@ -28,8 +31,14 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
|
||||
def _load_vec_extension(self, conn: sqlite3.Connection) -> None:
|
||||
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 regexp(expr: str, item: str | None) -> bool:
|
||||
@@ -44,13 +53,33 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
|
||||
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])
|
||||
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:
|
||||
ids = ",".join(["?" for _ in event_ids])
|
||||
self.execute_sql(f"DELETE FROM vec_descriptions WHERE id IN ({ids})", event_ids)
|
||||
self._delete_embeddings("vec_descriptions", event_ids)
|
||||
|
||||
def drop_embeddings_tables(self) -> None:
|
||||
self.execute_sql("""
|
||||
|
||||
@@ -93,7 +93,7 @@ class ModelConfig(BaseModel):
|
||||
model_type: ModelTypeEnum = Field(
|
||||
default=ModelTypeEnum.ssd,
|
||||
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()
|
||||
_colormap: dict[int, tuple[int, int, int]] = PrivateAttr()
|
||||
|
||||
@@ -366,9 +366,10 @@ class EventCleanup(threading.Thread):
|
||||
logger.debug(f"Deleting {len(chunk)} events from the database")
|
||||
Event.delete().where(Event.id << chunk).execute()
|
||||
|
||||
if self.config.semantic_search.enabled:
|
||||
self.db.delete_embeddings_description(event_ids=chunk)
|
||||
self.db.delete_embeddings_thumbnail(event_ids=chunk)
|
||||
logger.debug(f"Deleted {len(ids_to_delete)} embeddings")
|
||||
# embeddings are always cleaned up, even when semantic search
|
||||
# is disabled, so that they don't outlive their events
|
||||
self.db.delete_embeddings_description(event_ids=chunk)
|
||||
self.db.delete_embeddings_thumbnail(event_ids=chunk)
|
||||
logger.debug(f"Deleted {len(chunk)} embeddings")
|
||||
|
||||
logger.info("Exiting event cleanup...")
|
||||
|
||||
@@ -192,7 +192,7 @@ class LlamaCppClient(GenAIClient):
|
||||
logger.info(
|
||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
|
||||
configured_model,
|
||||
self._context_size or "unknown",
|
||||
self.get_context_size(),
|
||||
self._supports_vision,
|
||||
self._supports_audio,
|
||||
self._supports_tools,
|
||||
|
||||
@@ -335,6 +335,7 @@ class BirdsEyeFrameManager:
|
||||
|
||||
self.camera_layout: list[Any] = []
|
||||
self.active_cameras: set[str] = set()
|
||||
self.layout_camera_order: list[str] = []
|
||||
self.last_output_time = 0.0
|
||||
|
||||
def add_camera(self, cam: str) -> None:
|
||||
@@ -372,6 +373,13 @@ class BirdsEyeFrameManager:
|
||||
if cam in self.cameras:
|
||||
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:
|
||||
logger.debug("Clearing the birdseye frame")
|
||||
self.frame[:] = self.blank_frame
|
||||
@@ -482,6 +490,7 @@ class BirdsEyeFrameManager:
|
||||
# if the layout needs to be cleared
|
||||
self.camera_layout = []
|
||||
self.active_cameras = set()
|
||||
self.layout_camera_order = []
|
||||
self.clear_frame()
|
||||
frame_changed = True
|
||||
layout_changed = True
|
||||
@@ -500,21 +509,21 @@ class BirdsEyeFrameManager:
|
||||
else:
|
||||
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:
|
||||
logger.debug("Resetting Birdseye layout...")
|
||||
self.clear_frame()
|
||||
self.active_cameras = active_cameras
|
||||
self.layout_camera_order = sorted_active_cameras
|
||||
layout_changed = True # Layout is changing due to reset
|
||||
# this also converts added_cameras from a set to a list since we need
|
||||
# to pop elements in order
|
||||
active_cameras_to_add = sorted(
|
||||
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,
|
||||
),
|
||||
)
|
||||
active_cameras_to_add = sorted_active_cameras
|
||||
if len(active_cameras) == 1:
|
||||
# show single camera as fullscreen
|
||||
camera = active_cameras_to_add[0]
|
||||
@@ -780,6 +789,7 @@ class BirdsEyeFrameManager:
|
||||
frame_changed, layout_changed = False, False
|
||||
self.active_cameras = set()
|
||||
self.camera_layout = []
|
||||
self.layout_camera_order = []
|
||||
print(traceback.format_exc())
|
||||
|
||||
# if the frame was updated or the fps is too low, send frame
|
||||
|
||||
@@ -20,6 +20,10 @@ from norfair.camera_motion import (
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import (
|
||||
AUTOTRACKING_MAX_AREA_RATIO,
|
||||
AUTOTRACKING_MAX_MOVE_METRICS,
|
||||
@@ -194,7 +198,9 @@ class PtzAutoTrackerThread(threading.Thread):
|
||||
|
||||
def run(self):
|
||||
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:
|
||||
continue
|
||||
|
||||
@@ -211,6 +217,7 @@ class PtzAutoTrackerThread(threading.Thread):
|
||||
self.ptz_autotracker.tracked_object[camera] = None
|
||||
self.ptz_autotracker.tracked_object_history[camera].clear()
|
||||
|
||||
self.ptz_autotracker.config_subscriber.stop()
|
||||
logger.info("Exiting autotracker...")
|
||||
|
||||
|
||||
@@ -244,6 +251,16 @@ class PtzAutoTracker:
|
||||
self.zoom_time: dict[str, float] = {}
|
||||
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
|
||||
for camera, camera_config in self.config.cameras.items():
|
||||
if not camera_config.enabled:
|
||||
@@ -260,6 +277,29 @@ class PtzAutoTracker:
|
||||
# Wait for the coroutine to complete
|
||||
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):
|
||||
logger.debug(f"{camera}: Autotracker init")
|
||||
|
||||
@@ -1365,7 +1405,7 @@ class PtzAutoTracker:
|
||||
camera_config = self.config.cameras[camera]
|
||||
|
||||
if camera_config.onvif.autotracking.enabled:
|
||||
if not self.autotracker_init[camera]:
|
||||
if not self.autotracker_init.get(camera):
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._autotracker_setup(camera_config, camera), self.onvif.loop
|
||||
)
|
||||
@@ -1483,9 +1523,11 @@ class PtzAutoTracker:
|
||||
}
|
||||
|
||||
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 (
|
||||
not self.autotracker_init[camera]
|
||||
not self.autotracker_init.get(camera)
|
||||
or self.calibrating[camera]
|
||||
or self.tracked_object[camera] is not None
|
||||
):
|
||||
|
||||
+10
-9
@@ -344,16 +344,17 @@ class OnvifController:
|
||||
autotracking_config.enabled_in_config and autotracking_config.enabled
|
||||
)
|
||||
|
||||
# autotracking-only: status request and service capabilities
|
||||
if autotracking_enabled:
|
||||
status_request = ptz.create_type("GetStatus")
|
||||
status_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["status_request"] = status_request
|
||||
# these are local and cost nothing to build, and autotracking can be enabled
|
||||
# after a camera is initialized, so always create them rather than baking the
|
||||
# current config value into init state
|
||||
status_request = ptz.create_type("GetStatus")
|
||||
status_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["status_request"] = status_request
|
||||
|
||||
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
|
||||
self.cams[camera_name]["service_capabilities_request"] = (
|
||||
service_capabilities_request
|
||||
)
|
||||
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
|
||||
self.cams[camera_name]["service_capabilities_request"] = (
|
||||
service_capabilities_request
|
||||
)
|
||||
|
||||
# setup relative move request when FOV relative movement is supported
|
||||
if (
|
||||
|
||||
@@ -132,6 +132,77 @@ class TestHttpApp(BaseTestHttp):
|
||||
"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):
|
||||
# The plugin's list_models() returns [] on connection failure rather
|
||||
# than raising. The endpoint should surface that as success=false so
|
||||
|
||||
@@ -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
|
||||
|
||||
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):
|
||||
"""Write the minimal config to a temp YAML file and return the path."""
|
||||
yaml = ruamel.yaml.YAML()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Test camera user and password cleanup."""
|
||||
|
||||
import multiprocessing as mp
|
||||
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):
|
||||
@@ -45,3 +47,70 @@ class TestBirdseye(unittest.TestCase):
|
||||
canvas_width, canvas_height = get_canvas_shape(width, height)
|
||||
assert canvas_width == width # width will be the same
|
||||
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"]
|
||||
|
||||
@@ -8,9 +8,7 @@ from frigate.util.builtin import clean_camera_user_pass, escape_special_characte
|
||||
class TestUserPassCleanup(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.rtsp_with_pass = "rtsp://user:password@192.168.0.2:554/live"
|
||||
self.rtsp_with_special_pass = (
|
||||
"rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\{\}\[\]@@192.168.0.2:554/live"
|
||||
)
|
||||
self.rtsp_with_special_pass = "rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\\{\\}\\[\\]@@192.168.0.2:554/live"
|
||||
self.rtsp_no_pass = "rtsp://192.168.0.3:554/live"
|
||||
|
||||
def test_cleanup(self):
|
||||
|
||||
@@ -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.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):
|
||||
"""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._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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -491,6 +491,34 @@ class TestLlamaCppProvider(unittest.TestCase):
|
||||
final = _final_message(self._run_with_lines(client, lines, MULTIMODAL_MESSAGES))
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -21,8 +21,12 @@ class TestGpuStats(unittest.TestCase):
|
||||
@patch("frigate.util.services.time.sleep")
|
||||
@patch("frigate.util.services.time.monotonic")
|
||||
@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
|
||||
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"}
|
||||
|
||||
@@ -96,13 +100,15 @@ class TestGpuStats(unittest.TestCase):
|
||||
@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_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
|
||||
# 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
|
||||
# divided by capacity to land in 0-100%.
|
||||
drm_devices.return_value = {"0000:03:00.0": "xe"}
|
||||
monotonic.side_effect = [0.0, 1.0]
|
||||
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")
|
||||
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 = {}
|
||||
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
|
||||
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%",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -786,8 +786,15 @@ class TestProfileManager(unittest.TestCase):
|
||||
dispatcher.clear_runtime_state.assert_not_called()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_update_config_clears_when_active_profile_reapplies(self, mock_persist):
|
||||
"""After /api/config/set, an active-profile re-application drops state."""
|
||||
def test_update_config_preserves_runtime_state_with_active_profile(
|
||||
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()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed")
|
||||
@@ -795,7 +802,20 @@ class TestProfileManager(unittest.TestCase):
|
||||
|
||||
new_config = FrigateConfig(**self.config_data)
|
||||
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")
|
||||
def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -131,6 +131,25 @@ class TestRuntimeStatePersistence(unittest.TestCase):
|
||||
self.store.clear_all()
|
||||
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__":
|
||||
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"])
|
||||
@@ -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 ---
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
def test_admin_can_send_notification_test(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"notification_test", "admin", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Comma-separated roles ---
|
||||
|
||||
def test_comma_separated_admin_viewer_grants_admin(self):
|
||||
|
||||
@@ -684,22 +684,21 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
# check for config updates
|
||||
updated_topics = self.camera_config_subscriber.check_for_updates()
|
||||
|
||||
if "enabled" in updated_topics:
|
||||
for camera in updated_topics["enabled"]:
|
||||
if self.camera_states[camera].prev_enabled is None:
|
||||
self.camera_states[camera].prev_enabled = self.config.cameras[
|
||||
camera
|
||||
].enabled
|
||||
elif "add" in updated_topics:
|
||||
for camera in updated_topics["add"]:
|
||||
self.config.cameras[camera] = (
|
||||
self.camera_config_subscriber.camera_configs[camera]
|
||||
)
|
||||
self.create_camera_state(camera)
|
||||
elif "remove" in updated_topics:
|
||||
# a single drain can carry several topics at once, so add and
|
||||
# remove are handled independently rather than as exclusive branches
|
||||
for camera in updated_topics.get("add", []):
|
||||
self.config.cameras[camera] = (
|
||||
self.camera_config_subscriber.camera_configs[camera]
|
||||
)
|
||||
self.create_camera_state(camera)
|
||||
|
||||
if "remove" in updated_topics:
|
||||
for camera in updated_topics["remove"]:
|
||||
removed_camera_state = self.camera_states[camera]
|
||||
removed_camera_state.shutdown()
|
||||
camera_state = self.camera_states.get(camera)
|
||||
if camera_state is None:
|
||||
continue
|
||||
|
||||
camera_state.shutdown()
|
||||
self.camera_states.pop(camera)
|
||||
self.camera_activity.pop(camera, None)
|
||||
self.last_motion_detected.pop(camera, None)
|
||||
|
||||
@@ -326,7 +326,7 @@ def get_ort_providers(
|
||||
{
|
||||
"device_id": device_id,
|
||||
"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_engine_cache_enable": True,
|
||||
"trt_timing_cache_path": os.path.join(
|
||||
|
||||
+164
-24
@@ -285,6 +285,10 @@ _XE_ENGINE_KEYS = {
|
||||
"vecs": "video-enhance",
|
||||
"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:
|
||||
@@ -294,29 +298,70 @@ def _resolve_intel_gpu_pdev(device: str | None) -> str | None:
|
||||
if not device:
|
||||
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
|
||||
|
||||
name = os.path.basename(device.rstrip("/"))
|
||||
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:
|
||||
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.
|
||||
|
||||
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
|
||||
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 = {}
|
||||
|
||||
try:
|
||||
proc_entries = os.listdir("/proc")
|
||||
except OSError:
|
||||
return snapshot
|
||||
return None
|
||||
|
||||
for entry in proc_entries:
|
||||
if not entry.isdigit():
|
||||
@@ -400,41 +445,124 @@ def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict:
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
if not engines:
|
||||
continue
|
||||
|
||||
snapshot[key] = {"driver": driver, "pid": entry, "engines": engines}
|
||||
|
||||
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(
|
||||
intel_gpu_device: str | None,
|
||||
) -> dict[str, dict[str, Any]] | None:
|
||||
"""Get stats by reading DRM fdinfo files, bucketed per-pdev.
|
||||
|
||||
Each DRM client FD exposes monotonic per-engine busy counters via
|
||||
/proc/<pid>/fdinfo/<fd> (i915 since kernel 5.19, Xe since first release).
|
||||
We sample twice and divide busy-time deltas by 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%).
|
||||
/proc/<pid>/fdinfo/<fd>. For i915 this requires kernel 6.5 or newer:
|
||||
earlier kernels omit the per-engine counters whenever GuC submission is
|
||||
active, which is the default on 12th gen and newer. Xe has exposed them
|
||||
since its first release. We sample twice and divide busy-time deltas by
|
||||
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
|
||||
GPUs in the same system are reported separately. Each entry carries a
|
||||
"name" populated from OpenVINO (falling back to the pdev) so callers can
|
||||
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
|
||||
|
||||
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)
|
||||
if snapshot_a is None:
|
||||
logger.warning("Unable to collect Intel GPU stats: /proc could not be read")
|
||||
return None
|
||||
|
||||
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(
|
||||
"Unable to collect Intel GPU stats: no DRM fdinfo entries found"
|
||||
"%s. Check that /proc is readable and the i915/xe driver is loaded",
|
||||
f" for pdev {target_pdev}" if target_pdev else "",
|
||||
"Unable to collect Intel GPU stats: found %d DRM client(s) for %s but "
|
||||
"no per-engine counters. Kernel 6.5 or newer is required.",
|
||||
len(snapshot_a),
|
||||
"/".join(sorted({client["driver"] for client in snapshot_a.values()})),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -443,12 +571,18 @@ def get_intel_gpu_stats(
|
||||
elapsed_ns = (time.monotonic() - start) * 1e9
|
||||
|
||||
snapshot_b = _read_intel_drm_fdinfo(target_pdev)
|
||||
if not snapshot_b or elapsed_ns <= 0:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: second DRM fdinfo sample was empty"
|
||||
)
|
||||
if snapshot_b is None:
|
||||
logger.warning("Unable to collect Intel GPU stats: /proc could not be read")
|
||||
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]:
|
||||
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"]:
|
||||
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]
|
||||
engine_pct = per_pdev_engine_pct.setdefault(pdev, _new_engine_pct())
|
||||
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
|
||||
|
||||
if not per_pdev_engine_pct:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: no per-engine counters available "
|
||||
"(i915 requires kernel >= 5.19)"
|
||||
# Clients were seen in both snapshots but none persisted as the same
|
||||
# (pdev, client-id, pid); process churn, so report idle.
|
||||
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()
|
||||
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: "" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -322,7 +322,7 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -495,7 +495,7 @@
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
"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": {
|
||||
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
|
||||
|
||||
@@ -107,12 +107,7 @@
|
||||
},
|
||||
"npuUsage": "NPU Usage",
|
||||
"npuMemory": "NPU Memory",
|
||||
"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."
|
||||
}
|
||||
"npuTemperature": "NPU Temperature"
|
||||
},
|
||||
"otherProcesses": {
|
||||
"title": "Other Processes",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseRestreamStreamName } from "../theme/fields/streamSource";
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const arrayAsTextWidget = {
|
||||
@@ -42,6 +43,29 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
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: {
|
||||
hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets",
|
||||
|
||||
@@ -91,6 +91,7 @@ export default function BirdseyeCameraReorder({
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
update_topic: "config/cameras/*/birdseye",
|
||||
config_data: { cameras: cameraUpdates },
|
||||
});
|
||||
await updateConfig();
|
||||
|
||||
@@ -170,6 +170,12 @@ export function CameraInputsField(props: FieldProps) {
|
||||
[go2rtcStreamNames],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSourceModeByIndex((previous) =>
|
||||
Object.keys(previous).length > 0 ? {} : previous,
|
||||
);
|
||||
}, [formContext?.cameraName]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenByIndex((previous) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
@@ -222,18 +228,12 @@ export function CameraInputsField(props: FieldProps) {
|
||||
const handleSourceModeChange = useCallback(
|
||||
(index: number, nextMode: StreamSourceMode) => {
|
||||
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.
|
||||
if (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 });
|
||||
// Only revert the preset we set ourselves; never clobber custom args.
|
||||
// The path is left alone until a stream is picked, so switching modes
|
||||
// never discards a typed URL or empties a required field.
|
||||
if (nextMode === "manual" && input?.input_args === RESTREAM_PRESET) {
|
||||
handleFieldValuesChange(index, { input_args: undefined });
|
||||
}
|
||||
|
||||
setSourceModeByIndex((previous) => ({ ...previous, [index]: nextMode }));
|
||||
|
||||
@@ -160,6 +160,7 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
try {
|
||||
const res = await axios.post<ProbeResponse>("genai/probe", {
|
||||
provider: formProvider,
|
||||
name: providerKey,
|
||||
api_key:
|
||||
typeof formEntry.api_key === "string" ? formEntry.api_key : null,
|
||||
base_url:
|
||||
@@ -227,16 +228,18 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
aria-expanded={open}
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
"min-w-0 justify-between font-normal",
|
||||
!currentLabel && "text-muted-foreground",
|
||||
fieldClassName,
|
||||
)}
|
||||
>
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Select or enter a model…",
|
||||
})}
|
||||
<span className="truncate">
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Select or enter a model…",
|
||||
})}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -263,12 +266,14 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
value={trimmedSearch}
|
||||
onSelect={() => commit(trimmedSearch)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("configForm.genaiModel.useCustom", {
|
||||
ns: "views/settings",
|
||||
value: trimmedSearch,
|
||||
defaultValue: 'Use "{{value}}"',
|
||||
})}
|
||||
<Plus className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("configForm.genaiModel.useCustom", {
|
||||
ns: "views/settings",
|
||||
value: trimmedSearch,
|
||||
defaultValue: 'Use "{{value}}"',
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
@@ -287,11 +292,11 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
value === model ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model}
|
||||
<span className="truncate">{model}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
@@ -24,15 +24,19 @@ export function SemanticSearchModelSizeWidget(props: WidgetProps) {
|
||||
model !== "jinav1" &&
|
||||
model !== "jinav2";
|
||||
|
||||
// Clear model_size while on a provider (buildOverrides converts to ""
|
||||
// which the backend treats as "remove"). Restore the schema default
|
||||
// when returning to a Jina model so the field isn't left empty.
|
||||
// model_size is unused on a GenAI provider. Only clear it (which the backend
|
||||
// treats as "remove") for a non-default value, which can only come from the
|
||||
// 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 schemaDefault = schema?.default as string | undefined;
|
||||
useEffect(() => {
|
||||
if (isProvider && value !== undefined) {
|
||||
onChange(undefined);
|
||||
} else if (!isProvider && value === undefined && schemaDefault) {
|
||||
if (isProvider) {
|
||||
if (value !== undefined && value !== schemaDefault) {
|
||||
onChange(undefined);
|
||||
}
|
||||
} else if (value === undefined && schemaDefault) {
|
||||
onChange(schemaDefault);
|
||||
}
|
||||
}, [isProvider, value, onChange, schemaDefault]);
|
||||
|
||||
@@ -129,7 +129,7 @@ export function GeneralFilterContent({
|
||||
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
|
||||
htmlFor={item}
|
||||
>
|
||||
{item.replaceAll("_", " ")}
|
||||
{t(`logger.logLevel.${item}`, { ns: "views/settings" })}
|
||||
</Label>
|
||||
<Switch
|
||||
key={item}
|
||||
|
||||
@@ -153,11 +153,15 @@ export default function IconPicker({
|
||||
}
|
||||
|
||||
type IconRendererProps = {
|
||||
icon: IconType;
|
||||
icon: IconType | undefined;
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function IconRenderer({ icon, size, className }: IconRendererProps) {
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{React.createElement(icon, { size, className })}</>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LogSeverity } from "@/types/log";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { isIOS } from "react-device-detect";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ChipProps = {
|
||||
className?: string;
|
||||
@@ -50,6 +51,7 @@ type LogChipProps = {
|
||||
onClickSeverity?: () => void;
|
||||
};
|
||||
export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const severityClassName = useMemo(() => {
|
||||
switch (severity) {
|
||||
case "info":
|
||||
@@ -73,7 +75,7 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{severity}
|
||||
{t(`logger.logLevel.${severity}`, { ns: "views/settings" })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,6 +65,7 @@ import { Textarea } from "../ui/textarea";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { isValidIconName } from "@/utils/iconUtil";
|
||||
|
||||
const EXPORT_OPTIONS = [
|
||||
"1",
|
||||
@@ -443,10 +444,10 @@ export function ExportContent({
|
||||
}
|
||||
|
||||
setRange({
|
||||
before: latestTime,
|
||||
after: latestTime - 3600,
|
||||
before: currentTime + 1800,
|
||||
after: currentTime - 1800,
|
||||
});
|
||||
}, [activeTab, latestTime, range, setRange]);
|
||||
}, [activeTab, currentTime, range, setRange]);
|
||||
|
||||
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
|
||||
activeTab === "multi" && debouncedRange
|
||||
@@ -816,7 +817,19 @@ export function ExportContent({
|
||||
|
||||
<Tabs
|
||||
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")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
@@ -1066,7 +1079,11 @@ export function ExportContent({
|
||||
}
|
||||
>
|
||||
<IconRenderer
|
||||
icon={LuIcons[group.icon]}
|
||||
icon={
|
||||
isValidIconName(group.icon)
|
||||
? LuIcons[group.icon]
|
||||
: LuIcons.LuFolder
|
||||
}
|
||||
className="mr-2 size-4 text-secondary-foreground"
|
||||
/>
|
||||
<span className="truncate">{group.name}</span>
|
||||
|
||||
@@ -60,7 +60,10 @@ const CommandList = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -770,6 +770,34 @@ export default function MotionSearchView({
|
||||
};
|
||||
}, [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(() => {
|
||||
if (jobId && jobCamera) {
|
||||
void cancelMotionSearchJob(jobId, jobCamera);
|
||||
@@ -1238,7 +1266,7 @@ export default function MotionSearchView({
|
||||
<Toaster closeButton={true} position="top-center" />
|
||||
<MotionSearchDialog
|
||||
open={isSearchDialogOpen}
|
||||
onOpenChange={setIsSearchDialogOpen}
|
||||
onOpenChange={handleSearchDialogOpenChange}
|
||||
config={config}
|
||||
cameras={cameras}
|
||||
selectedCamera={selectedCamera}
|
||||
@@ -1276,7 +1304,7 @@ export default function MotionSearchView({
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => (onBack ? onBack() : navigate(-1))}
|
||||
onClick={handleBack}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
|
||||
@@ -470,50 +470,6 @@ export default function GeneralMetrics({
|
||||
return Object.keys(series).length > 0 ? Object.values(series) : undefined;
|
||||
}, [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
|
||||
|
||||
const npuSeries = useMemo(() => {
|
||||
@@ -819,46 +775,8 @@ export default function GeneralMetrics({
|
||||
<>
|
||||
{statsHistory.length != 0 ? (
|
||||
<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")}
|
||||
{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>
|
||||
{gpuSeries.map((series) => (
|
||||
<ThresholdBarGraph
|
||||
|
||||
Reference in New Issue
Block a user