Compare commits

..
Author SHA1 Message Date
Josh Hawkins 3f91ca32bb Keep the input path when switching stream source modes and reset mode overrides across cameras 2026-07-20 16:35:44 -05:00
Josh Hawkins c98efd4a02 warn when a camera input points at a missing go2rtc stream 2026-07-20 14:00:04 -05:00
Josh Hawkins 38d0fa8b06 clarify transcribe button 2026-07-20 12:29:54 -05:00
Josh Hawkins d7403c1010 tweak 2026-07-20 11:28:09 -05:00
Josh Hawkins 3e0f6c85de update debug info in face rec docs 2026-07-20 11:12:09 -05:00
Nicolas Mowen 45d3c6389e update docs 2026-07-19 17:25:16 -06:00
Nicolas Mowen 761a431c55 Use manual context size if set 2026-07-19 17:22:33 -06:00
Josh Hawkins ce0197cc8f fix semantic search model_size showing dirty for genai embeddings providers
The resolved config always reports model_size (schema default), so clearing it
whenever a provider was selected falsely marked the field dirty on load and sent
a delete for a YAML key that isn't there (Error updating config: 'model_size').
Only clear a non-default value, which is the only case actually present in the
config file.
2026-07-19 06:47:08 -05:00
Josh HawkinsandGitHub 6f80bcd19f Miscellaneous fixes (0.18 beta) (#23755)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* resolve saved credential sentinel to the stored api_key in the GenAI probe

* add profile faq

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

* add faq about preview restart cache

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

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

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

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

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

* fix notification test button being blocked by websocket auth

* fix overflowing model names in settings genai widget

* add note about auth debugging

---------

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

* fix: fix button components text

* fix: fix command components scrollbar

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

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

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

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

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

* sync runtime config on camera delete and prune its overrides

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

Extract the config swap that config_set already does into a shared swap_runtime_config helper and call it from both sites, so every collaborator is rebound and the surviving cameras' runtime toggles are re-layered. Also drop the deleted camera's persisted overrides via a new clear_camera so a camera later added under the same name does not inherit them.
2026-07-16 09:05:21 -05:00
Nicolas MowenandGitHub 70d629bf93 Update OpenVINO model generation (#23733)
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
2026-07-16 08:00:52 -06:00
Josh HawkinsandGitHub c406a93d3d Miscellaneous fixes (0.18 beta) (#23725)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-07-15 18:49:05 -06:00
61 changed files with 1947 additions and 263 deletions
+1
View File
@@ -12,6 +12,7 @@ config/*
models
*.mp4
*.db
*.db-*
*.csv
frigate/version.py
web/build
+2 -2
View File
@@ -81,10 +81,10 @@ RUN --mount=type=bind,source=docker/main/install_tempio.sh,target=/deps/install_
FROM base_host AS ov-converter
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" \
+39 -8
View File
@@ -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)
+1 -2
View File
@@ -1,3 +1,2 @@
numpy
tensorflow
openvino-dev>=2024.0.0
openvino >= 2026.2.0
+2 -2
View File
@@ -272,7 +272,7 @@ If you have CUDA hardware, you can experiment with the `large` `whisper` model o
#### Transcription and translation of `speech` audio events
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.
+13
View File
@@ -262,6 +262,19 @@ In this example:
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
:::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)**
+16 -2
View File
@@ -232,7 +232,21 @@ Once front-facing images are performing well, start choosing slightly off-angle
Start with the [Usage](#usage) section and re-read the [Model Requirements](#model-requirements) above.
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).
+4 -2
View File
@@ -78,7 +78,7 @@ 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">
@@ -89,12 +89,14 @@ genai:
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.
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.
+15
View File
@@ -232,6 +232,21 @@ No. Only one profile can be active at a time. Activating a new profile automatic
When you delete a base zone or mask in the Frigate UI, any profile overrides for that entry are deleted automatically as part of the same operation. If you remove a base entry by editing your config file directly and leave a profile override behind, the config will fail validation at startup until the orphaned override is removed as well.
### 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.
+25 -2
View File
@@ -78,7 +78,7 @@ Users of the Snapcraft build of Docker cannot use storage locations outside your
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
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
+16
View File
@@ -428,3 +428,19 @@ You'll want to:
- [Tune your motion detection settings](/configuration/motion_detection) either by editing your config file or by using the UI's Motion Tuner.
</FaqItem>
<FaqItem id="my-timeline-previews-are-black-after-restarting-frigate-or-recreating-the-container" question="My timeline previews are black after restarting Frigate or recreating the container. Why?">
The scrubbing previews (the timelapse clips shown when dragging the History timeline, the secondary-camera previews, and the preview that plays when hovering a review card) are not recorded continuously. Frigate caches low-resolution preview frames in `/tmp/cache` throughout each hour and only assembles them into a finished preview clip **at the top of the hour**.
In the recommended configuration, `/tmp/cache` is a small in-memory (`tmpfs`) area. When Frigate starts, it tries to restore the current hour's cached frames, so a **soft restart from the UI** preserves them. But if you recreate the Docker container or stop Frigate forcibly by any other means partway through an hour, the in-memory cache is discarded, so no preview clip is produced for that partial hour.
This is expected behavior, not a bug:
- Previews for hours that already completed and were written to disk are unaffected.
- The next full hour after a restart will generate previews normally.
- This is unrelated to `shm_size`; increasing shared memory does not change it.
To avoid the gap, use the **Restart Frigate** button in the UI's Settings menu rather than recreating the container when possible.
</FaqItem>
+1 -1
View File
@@ -34,7 +34,7 @@ All of your exports live on the **Exports** page, reachable from the main naviga
- **Rename** it, and
- **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
+3 -4
View File
@@ -19,7 +19,6 @@
"docusaurus-plugin-openapi-docs": "^4.5.1",
"docusaurus-theme-openapi-docs": "^4.5.1",
"js-yaml": "^4.1.1",
"marked": "^16.4.2",
"prism-react-renderer": "^2.4.1",
"raw-loader": "^4.0.2",
"react": "^18.3.1",
@@ -23020,9 +23019,9 @@
}
},
"node_modules/websocket-driver": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz",
"integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==",
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
"license": "Apache-2.0",
"dependencies": {
"http-parser-js": ">=0.5.1",
+5
View File
@@ -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
View File
@@ -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,
+9 -3
View File
@@ -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(
+35
View File
@@ -0,0 +1,35 @@
"""Shared helpers for applying a freshly parsed config to the running app."""
from fastapi import FastAPI
from frigate.config import FrigateConfig
def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
"""Point every long-lived collaborator at a newly parsed config object.
Both /api/config/set and camera deletion re-parse yaml into a fresh
FrigateConfig and must rebind the same set of references, or the API and
the dispatcher drift onto different objects (the API reports one camera
state while the dispatcher acts on another). Runtime toggle overrides are
re-layered last: the swap rebuilt every camera from yaml, so without this a
camera the user turned off would silently come back on.
"""
app.frigate_config = config
app.genai_manager.update_config(config)
if app.profile_manager is not None:
app.profile_manager.update_config(config)
if app.stats_emitter is not None:
app.stats_emitter.config = config
if app.dispatcher is not None:
app.dispatcher.config = config
for comm in app.dispatcher.comms:
comm.config = config
# workers still hold the live toggle values, so correct only the
# config object here rather than re-broadcasting every override
app.dispatcher.reapply_runtime_state_to_config()
+1
View File
@@ -14,6 +14,7 @@ class AppConfigSetBody(BaseModel):
class GenAIProbeBody(BaseModel):
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)
+3 -1
View File
@@ -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,
+2 -2
View File
@@ -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
]
+87 -7
View File
@@ -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:
+19
View File
@@ -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.
-2
View File
@@ -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,
}
)
+3
View File
@@ -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:
+5 -4
View File
@@ -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 -1
View File
@@ -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,
+18 -8
View File
@@ -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
+46 -4
View File
@@ -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
View File
@@ -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 (
+71
View File
@@ -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
+132
View File
@@ -0,0 +1,132 @@
"""Tests for the camera delete endpoint's runtime config handling."""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, Mock, patch
import ruamel.yaml
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestDeleteCameraRuntimeConfig(BaseTestHttp):
"""Deleting a camera must keep the API and dispatcher on the same config."""
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"back_yard": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 720, "width": 1280, "fps": 10},
},
},
}
def _write_config_file(self):
yaml = ruamel.yaml.YAML()
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
yaml.dump(self.minimal_config, f)
f.close()
return f.name
def _create_app_with_dispatcher(self, dispatcher):
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
dispatcher=dispatcher,
enforce_default_admin=False,
)
async def mock_get_current_user(request: Request):
return {
"username": request.headers.get("remote-user"),
"role": request.headers.get("remote-role"),
}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app, mock_publisher
@patch("frigate.api.camera.requests.delete")
@patch("frigate.api.camera.cleanup_camera_files")
@patch("frigate.api.camera.cleanup_camera_db")
@patch("frigate.api.camera.find_config_file")
def test_delete_syncs_dispatcher_and_prunes_runtime_state(
self, mock_find_config, mock_cleanup_db, mock_cleanup_files, mock_go2rtc_delete
):
"""Deleting a camera swaps every config reference and prunes its state."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
mock_cleanup_db.return_value = ({}, [])
dispatcher = MagicMock()
dispatcher.comms = []
try:
app, _ = self._create_app_with_dispatcher(dispatcher)
with AuthTestClient(app) as client:
resp = client.delete("/cameras/front_door")
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()["success"])
# the dispatcher must be moved onto the same new object the API
# now serves, and that object must no longer contain the camera
self.assertIs(dispatcher.config, app.frigate_config)
self.assertNotIn("front_door", dispatcher.config.cameras)
self.assertIn("back_yard", dispatcher.config.cameras)
# surviving cameras' overrides are re-layered onto the new object
dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
# the deleted camera's persisted overrides are pruned
dispatcher.clear_runtime_state_for_camera.assert_called_once_with(
"front_door"
)
finally:
os.unlink(config_path)
if __name__ == "__main__":
unittest.main()
@@ -91,6 +91,123 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
return app, mock_publisher
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()
+70 -1
View File
@@ -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"]
+1 -3
View File
@@ -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):
+55
View File
@@ -0,0 +1,55 @@
"""Tests for the shared runtime config swap helper."""
import unittest
from unittest.mock import MagicMock
from frigate.api.config_util import swap_runtime_config
class TestSwapRuntimeConfig(unittest.TestCase):
"""swap_runtime_config rebinds every collaborator to the new config."""
def _make_app(self) -> MagicMock:
app = MagicMock()
app.dispatcher.comms = [MagicMock(), MagicMock()]
return app
def test_rebinds_all_references(self) -> None:
app = self._make_app()
config = MagicMock(name="new_config")
swap_runtime_config(app, config)
self.assertIs(app.frigate_config, config)
app.genai_manager.update_config.assert_called_once_with(config)
app.profile_manager.update_config.assert_called_once_with(config)
self.assertIs(app.stats_emitter.config, config)
self.assertIs(app.dispatcher.config, config)
for comm in app.dispatcher.comms:
self.assertIs(comm.config, config)
def test_reapplies_runtime_state_after_swap(self) -> None:
app = self._make_app()
config = MagicMock(name="new_config")
swap_runtime_config(app, config)
# the swap rebuilds cameras from yaml, so overrides must be re-layered
app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
def test_tolerates_missing_optional_collaborators(self) -> None:
app = MagicMock()
app.profile_manager = None
app.stats_emitter = None
app.dispatcher = None
config = MagicMock(name="new_config")
# must not raise when the optional collaborators are absent
swap_runtime_config(app, config)
self.assertIs(app.frigate_config, config)
app.genai_manager.update_config.assert_called_once_with(config)
if __name__ == "__main__":
unittest.main()
@@ -126,6 +126,40 @@ class TestRestoreRuntimeState(unittest.TestCase):
self.dispatcher.restore_runtime_state()
self.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()
+28
View File
@@ -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()
+147 -3
View File
@@ -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%",
},
}
+23 -3
View File
@@ -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):
+130
View File
@@ -0,0 +1,130 @@
"""Tests for autotracker state that must survive runtime config changes.
Regression coverage for a family of bugs where per-camera autotracker state was
built once at startup and never revisited. A camera that is added or enabled
after startup, or has autotracking enabled from the UI, would either raise a
KeyError on the autotracker thread or silently keep the wrong state:
- autotracker_init only got an entry for cameras enabled when PtzAutoTracker was
constructed, so runtime-enabled cameras raised KeyError on lookup.
- ptz_metrics autotracker_enabled is what the camera processes read, but nothing
updated it when autotracking was enabled through a config save, so it stayed
False and the tracker never built a motion estimator.
"""
import unittest
from unittest.mock import MagicMock
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig
from frigate.ptz.autotrack import PtzAutoTracker
CAMERA = "ptz_cam"
def _config(autotracking_enabled: bool) -> FrigateConfig:
return FrigateConfig(
**{
"mqtt": {"enabled": False},
"cameras": {
CAMERA: {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"width": 1920, "height": 1080},
"zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}},
"onvif": {
"host": "10.0.0.1",
"autotracking": {
"enabled": autotracking_enabled,
"required_zones": ["zone"],
},
},
}
},
}
)
def _make_tracker(autotracking_enabled: bool = True) -> PtzAutoTracker:
"""Build a PtzAutoTracker without invoking __init__, which would try to set up
onvif over the network. Only the config/metrics state is relevant here."""
tracker = PtzAutoTracker.__new__(PtzAutoTracker)
tracker.config = _config(autotracking_enabled)
tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)}
tracker.onvif = MagicMock()
tracker.config_subscriber = MagicMock()
tracker.autotracker_init = {}
tracker.calibrating = {}
tracker.tracked_object = {}
return tracker
class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase):
async def test_camera_maintenance_returns_early_when_not_initialized(self) -> None:
# a camera enabled at runtime has no autotracker_init entry, which used to
# raise KeyError and kill the autotracker thread for every camera
tracker = _make_tracker()
self.assertNotIn(CAMERA, tracker.autotracker_init)
await tracker.camera_maintenance(CAMERA)
tracker.onvif.get_camera_status.assert_not_called()
async def test_camera_maintenance_returns_early_when_init_incomplete(self) -> None:
# autotracker_init is seeded False for enabled cameras before setup runs
tracker = _make_tracker()
tracker.autotracker_init[CAMERA] = False
await tracker.camera_maintenance(CAMERA)
tracker.onvif.get_camera_status.assert_not_called()
class TestAutotrackerMetricSync(unittest.TestCase):
def test_metric_follows_config_when_enabled_by_update(self) -> None:
# autotracking enabled via a config save: the metric was seeded False when
# the camera was added and nothing else updates it
tracker = _make_tracker(autotracking_enabled=True)
metrics = tracker.ptz_metrics[CAMERA]
self.assertFalse(metrics.autotracker_enabled.value)
tracker.config_subscriber.check_for_updates.return_value = {"onvif": [CAMERA]}
tracker.check_for_updates()
self.assertTrue(metrics.autotracker_enabled.value)
def test_metric_follows_config_when_disabled_by_update(self) -> None:
tracker = _make_tracker(autotracking_enabled=False)
metrics = tracker.ptz_metrics[CAMERA]
metrics.autotracker_enabled.value = True
tracker.config_subscriber.check_for_updates.return_value = {
"autotracking": [CAMERA]
}
tracker.check_for_updates()
self.assertFalse(metrics.autotracker_enabled.value)
def test_metric_sync_skips_camera_without_metrics(self) -> None:
# `add` reaches the maintainer and the autotracker on separate threads with
# no ordering guarantee, so the metrics may not exist yet
tracker = _make_tracker()
tracker.ptz_metrics = {}
tracker.config_subscriber.check_for_updates.return_value = {"add": [CAMERA]}
tracker.check_for_updates()
def test_metric_sync_skips_unknown_camera(self) -> None:
tracker = _make_tracker()
tracker.config_subscriber.check_for_updates.return_value = {
"add": ["not_in_config"]
}
tracker.check_for_updates()
if __name__ == "__main__":
unittest.main()
+147
View File
@@ -0,0 +1,147 @@
"""Tests for ONVIF init state that must not depend on the autotracking config.
Regression coverage for a camera that is initialized while autotracking is off and
has it enabled later, which is the normal wizard flow: set the camera up first,
configure autotracking afterwards. The autotracking-only request objects used to
be created only when autotracking was enabled at init time, so the camera was left
with init=True but no status_request. get_camera_status skips its re-init branch
when init is True, so it went straight to the missing key and raised KeyError on
the tracking thread.
The request objects are built from the locally parsed WSDL and cost no network, so
they are always created and init=True now implies they exist.
"""
import unittest
from unittest.mock import AsyncMock, MagicMock
from frigate.config import FrigateConfig
from frigate.ptz.onvif import OnvifController
CAMERA = "ptz_cam"
def _config(autotracking_enabled: bool) -> FrigateConfig:
return FrigateConfig(
**{
"mqtt": {"enabled": False},
"cameras": {
CAMERA: {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"width": 1920, "height": 1080},
"zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}},
"onvif": {
"host": "10.0.0.1",
"autotracking": {
"enabled": autotracking_enabled,
"required_zones": ["zone"],
},
},
}
},
}
)
def _make_profile() -> MagicMock:
profile = MagicMock()
profile.token = "profile_1"
profile.Name = "MainStream"
profile.VideoEncoderConfiguration = MagicMock()
ptz_config = MagicMock()
ptz_config.token = "ptz_config_1"
ptz_config.DefaultContinuousPanTiltVelocitySpace = "space"
ptz_config.DefaultContinuousZoomVelocitySpace = "space"
profile.PTZConfiguration = ptz_config
return profile
def _make_onvif_camera() -> MagicMock:
"""A camera that supports PTZ but nothing optional, so init takes the simplest
path through the feature detection below."""
onvif = MagicMock()
onvif.update_xaddrs = AsyncMock()
video_source = MagicMock()
video_source.token = "video_source_1"
media = MagicMock()
media.GetProfiles = AsyncMock(return_value=[_make_profile()])
media.GetVideoSources = AsyncMock(return_value=[video_source])
onvif.create_media_service = AsyncMock(return_value=media)
onvif.get_definition = MagicMock(return_value={"ptz": "definition"})
ptz = MagicMock()
# create_type is a local WSDL lookup, so tag the result to assert on it later
ptz.create_type = MagicMock(side_effect=lambda name: MagicMock(request_type=name))
ptz.GetConfigurationOptions = AsyncMock(side_effect=Exception("not supported"))
onvif.create_ptz_service = AsyncMock(return_value=ptz)
onvif.create_imaging_service = AsyncMock(side_effect=Exception("not supported"))
return onvif
def _make_controller(autotracking_enabled: bool) -> OnvifController:
"""Build a controller without invoking __init__, which would start an event loop
thread and reach out to the camera."""
config = _config(autotracking_enabled)
controller = OnvifController.__new__(OnvifController)
controller.config = config
controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}}
controller.failed_cams = {}
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
controller.ptz_metrics = {CAMERA: MagicMock()}
return controller
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
async def test_status_request_created_when_autotracking_disabled(self) -> None:
# the wizard flow: onvif configured first, autotracking enabled later
controller = _make_controller(autotracking_enabled=False)
self.assertTrue(await controller._init_onvif(CAMERA))
cam = controller.cams[CAMERA]
self.assertTrue(cam["init"])
self.assertIn("status_request", cam)
self.assertIn("service_capabilities_request", cam)
async def test_status_request_created_when_autotracking_enabled(self) -> None:
controller = _make_controller(autotracking_enabled=True)
self.assertTrue(await controller._init_onvif(CAMERA))
cam = controller.cams[CAMERA]
self.assertIn("status_request", cam)
self.assertIn("service_capabilities_request", cam)
async def test_init_implies_status_request_exists(self) -> None:
# the invariant get_camera_status relies on: it skips re-init when init is
# True and then reads status_request without guarding
for autotracking_enabled in (True, False):
with self.subTest(autotracking_enabled=autotracking_enabled):
controller = _make_controller(autotracking_enabled)
await controller._init_onvif(CAMERA)
cam = controller.cams[CAMERA]
if cam["init"]:
self.assertEqual(cam["status_request"].request_type, "GetStatus")
async def test_requests_built_without_contacting_camera(self) -> None:
# create_type is a local WSDL lookup; cameras that do not implement
# GetServiceCapabilities must not be asked about it during init
controller = _make_controller(autotracking_enabled=False)
await controller._init_onvif(CAMERA)
ptz = controller.cams[CAMERA]["ptz"]
ptz.GetServiceCapabilities.assert_not_called()
ptz.GetStatus.assert_not_called()
if __name__ == "__main__":
unittest.main()
+19
View File
@@ -131,6 +131,25 @@ class TestRuntimeStatePersistence(unittest.TestCase):
self.store.clear_all()
self.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()
+14
View File
@@ -115,6 +115,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
)
)
def test_viewer_blocked_from_notification_test(self):
self.assertFalse(
_check_ws_authorization(
"notification_test", "viewer", self.DEFAULT_SEPARATOR
)
)
# --- Admin access ---
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):
+14 -15
View File
@@ -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)
+164 -24
View File
@@ -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: "" },
},
});
});
});
+2 -1
View File
@@ -1936,7 +1936,8 @@
"inputDimensionsNotDetectResolution": "Model input width and height are the input dimensions of the object detection model, not your camera's detect resolution. They should match the dimensions of the model you're using — typically a square size like 320x320 or 640x640."
},
"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."
+1 -6
View File
@@ -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}
+5 -1
View File
@@ -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 -1
View File
@@ -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>
);
+22 -5
View File
@@ -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>
+4 -1
View File
@@ -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 && (
+1 -83
View File
@@ -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