Compare commits

..
Author SHA1 Message Date
GuoQing LiuandGitHub 2c2b79302d Merge edca412856 into a8eca68438 2026-07-15 16:53:01 +08:00
ZhaiSoul edca412856 docs: Add docs Frigate UI mock view 2026-07-12 21:34:08 +08:00
123 changed files with 11049 additions and 3252 deletions
-1
View File
@@ -82,7 +82,6 @@ frontdoor
fstype
fullchain
fullscreen
gatekeep
genai
generativeai
genpts
@@ -10,11 +10,8 @@ body:
Before submitting, read the [beta documentation][docs].
By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response.
[docs]: https://docs-dev.frigate.video/
[discussions]: https://github.com/blakeblackshear/frigate/discussions
[ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md
- type: textarea
id: description
attributes:
@@ -8,12 +8,9 @@ body:
Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community.
By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response.
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
[ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md
- type: textarea
id: description
attributes:
@@ -8,12 +8,9 @@ body:
Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community.
By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response.
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
[ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md
- type: textarea
id: description
attributes:
@@ -8,12 +8,9 @@ body:
Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community.
By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response.
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
[ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md
- type: textarea
id: description
attributes:
@@ -8,12 +8,9 @@ body:
Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community.
By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response.
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
[ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md
- type: textarea
id: description
attributes:
@@ -8,12 +8,9 @@ body:
Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community.
By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response.
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
[ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md
- type: textarea
id: description
attributes:
-3
View File
@@ -10,12 +10,9 @@ body:
**If you are looking for support, start a new discussion and use a support category.**
By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response.
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
[ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md
- type: textarea
id: description
attributes:
@@ -12,14 +12,11 @@ body:
**If you are unsure if your issue is actually a bug or not, please submit a support request first.**
By posting here you agree to follow our [AI policy][ai-policy]. Posts that appear to be written by an AI on your behalf may be closed without a response.
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[prs]: https://www.github.com/blakeblackshear/frigate/pulls
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
[ai]: https://docs.frigate.video
[ai-policy]: https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md
- type: checkboxes
attributes:
label: Checklist
@@ -7,13 +7,6 @@ assignees: ''
---
<!--
By posting here you agree to follow our AI policy:
https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md
Requests that appear to be written by an AI on your behalf may be closed without a response.
-->
**Describe what you are trying to accomplish and why in non technical terms**
I want to be able to ... so that I can ...
+1 -1
View File
@@ -1,4 +1,4 @@
_Please read the [contributing guidelines](https://github.com/blakeblackshear/frigate/blob/dev/CONTRIBUTING.md) and the [AI policy](https://github.com/blakeblackshear/frigate/blob/dev/AI_POLICY.md) before submitting a PR. Every PR must be read and submitted by a person, and PRs that appear to be unreviewed AI output will be closed without review._
_Please read the [contributing guidelines](https://github.com/blakeblackshear/frigate/blob/dev/CONTRIBUTING.md) before submitting a PR._
## Proposed change
-1
View File
@@ -12,7 +12,6 @@ config/*
models
*.mp4
*.db
*.db-*
*.csv
frigate/version.py
web/build
-126
View File
@@ -1,126 +0,0 @@
# Frigate AI Policy
## TL;DR
- **Use AI tools if they help you.** We do too. This is about what you post, not which tools you use to write it.
- **A person has to read it and send it.** Don't wire a bot or an agent up to post on your behalf.
- **Write your posts yourself.** Your own words, the template filled in, and you answering maintainers rather than your assistant.
- **Don't paste an AI's guess at the cause as though it were a diagnosis.** Tell us what you actually observed.
- **Read your code before you submit it.** Disclose that AI was used, and be ready to explain every line.
- **If we misjudge something you wrote, just say so.** We'll take you at your word.
The rest of this document explains each of these, and why.
## Scope
AI tools are a reality of modern development and we're not opposed to their use. You are responsible for anything you submit, however it was produced, and we are responsible for anything we merge and release. We hold a high bar for both.
This policy applies everywhere this project is discussed: issues, discussions, pull requests, code reviews, and commit comments.
## Why this exists
Frigate is built and supported by a small group of maintainers and a community of volunteers who read every post and review every pull request. Nobody here is paid to do it, and time spent reading a post is time not spent fixing bugs or building features.
We're not opposed to AI tools. We use them too. But content generated by an AI and submitted without review costs a real person real time, and usually gives them less to work with than a few honest sentences would have. That is the problem this policy addresses.
## A person has to be in the loop
Every issue, discussion, comment, and pull request here must be read and submitted by a person. Using an AI tool to help you write is fine. Wiring one up to post on your behalf is not.
Specifically, do not:
- Connect a bot or agent to GitHub that opens issues, discussions, or pull requests without you reading them first
- Post output from a tool you have not read
- Use tooling to file bulk or drive-by contributions across the repository
We will close anything we believe was posted without a person reading it, and we may mark it as spam. Posts that skip the templates are the most common sign of this.
## Issues, discussions, and comments
We do not mind if you use AI tools to help you write. Do not have tools post unreviewed content on your behalf. We may hide any comment we believe to be unreviewed AI output.
Keep posts to what is needed to communicate your point. A long, confidently written, AI-padded post is harder to help with than a short direct one, not easier, and it is usually obvious.
**Describe your actual problem in your own words.** Tell us what you did, what you expected, and what actually happened. That is the information we need, and only you have it.
**Do not paste an AI's guess at the cause as though it were a diagnosis.** It is frequently wrong in ways that send everyone down the wrong path, and it buries the details that would have led to the real answer. We would rather see what you observed than what a model inferred.
**Fill in the template completely.** The templates ask for logs, config, version, and hardware because those are the things needed to help you. An AI cannot supply them for you, and a post missing them cannot be acted on.
**Answer maintainers yourself.** If we ask you a question, we are asking _you_, not your AI assistant. These are the spaces where we build trust and understanding with the community, and that only works if we're talking to each other. Using AI to fix your grammar or clarity is fine, but the substance has to be yours.
This applies to pull request descriptions and review replies as much as it does to bug reports and discussions.
### Quoting AI output
If you want to include something an AI told you, it must be:
- In a quote block, using `>`
- Disclosed as AI output, saying which tool it came from
- Accompanied by your own comment explaining why you think it is relevant
Keep the excerpt short. Do not paste long transcripts.
### Non-native English speakers
AI is genuinely useful for participating in a project that operates in English, and we would rather hear from you through a translation tool than not hear from you at all. Using AI to improve the grammar or clarity of something you wrote yourself is fine.
If you are translating your posts, make sure the translation says what you meant. Including your original text in a `<details>` block helps us verify the translation if something reads oddly, and keeps the thread readable.
## Code contributions
We need to understand your relationship with the code you're submitting. The more AI was involved, the more important it is that you've genuinely reviewed, tested, and understood what it produced.
Because of the long-term maintenance burden every merged change creates, we require a human in the loop who understands the work the AI produced. Pull requests that appear to be unreviewed AI output will be closed without review.
### Requirements when AI is used
If AI is used to generate any portion of the code, contributors must adhere to the following requirements:
1. **Explicitly disclose the manner in which AI was employed.** The PR template asks for this. Be honest, this won't automatically disqualify your PR. We'd rather have an honest disclosure than find out later. Trust matters more than method.
2. **Perform a comprehensive manual review prior to submitting the pull request.** Don't submit code you haven't read carefully and tested locally.
3. **Be prepared to explain every line of code you submitted when asked about it by a maintainer.** If you can't explain why something works the way it does, you're not ready to submit it.
4. **Check for an existing pull request addressing the same change.** If one exists, comment there and work with its author instead of opening a duplicate.
5. **It is strictly prohibited to use AI to write your posts for you** (bug reports, feature requests, pull request descriptions, GitHub discussions, responding to humans, etc.). We need to hear from _you_, not your AI assistant. These are the spaces where we build trust and understanding with contributors, and that only works if we're talking to each other.
### Established contributors
Contributors with a long history of thoughtful, quality contributions to Frigate have earned trust through that track record. The level of scrutiny we apply to AI usage naturally reflects that trust. This isn't a formal exemption, it's just how trust works. If you've been around, we know how you think and how you work. If you're new, we're still getting to know you, and clear disclosure helps build that relationship.
### What this means in practice
We're not trying to gatekeep how you write code. Use whatever tools make you productive. But there's a difference between using AI as a tool to implement something you understand and handing a feature request to an AI and submitting whatever comes back. The former is fine. The latter creates maintenance risk for the project.
Some honest context: when we review a PR, we're not just evaluating whether the code works today. We're evaluating whether we can maintain it, debug it, and extend it long-term, often without the original author's involvement. Code that the author doesn't deeply understand is code that nobody understands, and that's a liability.
One more thing worth saying directly: most maintainers already have access to the same AI tools you do. A PR that's entirely AI-generated, where the author can't explain the design, debug issues independently, or engage substantively in design discussions, doesn't offer something we couldn't produce ourselves. What makes a contribution genuinely valuable is the human judgment and domain understanding behind it, as well as the engagement during review that shapes it into something we can confidently take on long-term.
## Our use of AI
The Frigate documentation site has an "Ask AI" search that answers questions from the docs, and we may use AI tooling to help with triage and project management. Like any automated tooling, it is not always right.
If an AI tool leaves a comment on your contribution, treat it the way you would any other comment. If you think it is wrong, say so, and a brief explanation is enough. Maintainers always have the final say.
## Enforcement
Contributions and posts that do not follow this policy will be closed. Depending on the situation, maintainers may also:
- Hide or delete comments that appear to be unreviewed AI output
- Mark automated content as spam
- Close an issue, discussion, or pull request without further review
- Lock a conversation
- Temporarily or permanently block an account from participating in the project
Repeated violations may result in being blocked from contributing to Frigate.
### When we get it wrong
There is no reliable way to detect this, and we're not going to pretend otherwise. Whether something reads as unreviewed AI output is a judgment call, usually made quickly, by a volunteer with limited time and no way to know for certain. These calls are subjective and we won't always get them right.
If it happens to you, just say so. A short reply telling us you wrote it yourself is enough, and we'll take you at your word and pick the conversation back up. We would much rather occasionally reopen something we misjudged than treat everyone who posts here as a suspect.
We'd ask for some understanding in return. These calls get made quickly because the volume is real, and time spent second-guessing them is time not spent helping the person in the next thread.
## Attribution
Portions of this policy are adapted from the [Open Home Foundation AI Policy](https://developers.home-assistant.io/docs/ai_policy/).
+19 -9
View File
@@ -2,8 +2,6 @@
Thank you for your interest in contributing to Frigate. This document covers the expectations and guidelines for contributions. Please read it before submitting a pull request.
All participation in this project, including pull requests, issues, and discussions, is covered by our [AI policy](AI_POLICY.md).
## Before you start
### Bugfixes
@@ -23,16 +21,28 @@ Before writing code for a new feature:
## AI usage policy
AI tools are a reality of modern development and we're not opposed to their use. But we need to understand your relationship with the code you're submitting, and we need to hear from you rather than from your AI assistant.
AI tools are a reality of modern development and we're not opposed to their use. But we need to understand your relationship with the code you're submitting. The more AI was involved, the more important it is that you've genuinely reviewed, tested, and understood what it produced.
**Read the [AI policy](AI_POLICY.md) before you open a pull request.** It is short, and it applies to everything you post here. The parts that most often catch people out:
### Requirements when AI is used
- A person has to be in the loop. Don't wire a bot or agent up to open pull requests, issues, or discussions on your behalf.
- Disclose how AI was used. The PR template asks for this. Be honest, it won't automatically disqualify your PR.
- Review and test everything you submit, and be prepared to explain every line when asked.
- Don't use AI to write your PR description or your replies to maintainers.
If AI is used to generate any portion of the code, contributors must adhere to the following requirements:
Pull requests that appear to be unreviewed AI output will be closed without review.
1. **Explicitly disclose the manner in which AI was employed.** The PR template asks for this. Be honest — this won't automatically disqualify your PR. We'd rather have an honest disclosure than find out later. Trust matters more than method.
2. **Perform a comprehensive manual review prior to submitting the pull request.** Don't submit code you haven't read carefully and tested locally.
3. **Be prepared to explain every line of code they submitted when asked about it by a maintainer.** If you can't explain why something works the way it does, you're not ready to submit it.
4. **It is strictly prohibited to use AI to write your posts for you** (bug reports, feature requests, pull request descriptions, GitHub discussions, responding to humans, etc.). We need to hear from _you_, not your AI assistant. These are the spaces where we build trust and understanding with contributors, and that only works if we're talking to each other.
### Established contributors
Contributors with a long history of thoughtful, quality contributions to Frigate have earned trust through that track record. The level of scrutiny we apply to AI usage naturally reflects that trust. This isn't a formal exemption — it's just how trust works. If you've been around, we know how you think and how you work. If you're new, we're still getting to know you, and clear disclosure helps build that relationship.
### What this means in practice
We're not trying to gatekeep how you write code. Use whatever tools make you productive. But there's a difference between using AI as a tool to implement something you understand and handing a feature request to an AI and submitting whatever comes back. The former is fine. The latter creates maintenance risk for the project.
Some honest context: when we review a PR, we're not just evaluating whether the code works today. We're evaluating whether we can maintain it, debug it, and extend it long-term — often without the original author's involvement. Code that the author doesn't deeply understand is code that nobody understands, and that's a liability.
One more thing worth saying directly: most maintainers already have access to the same AI tools you do. A PR that's entirely AI-generated — where the author can't explain the design, debug issues independently, or engage substantively in design discussions — doesn't offer something we couldn't produce ourselves. What makes a contribution genuinely valuable is the human judgment and domain understanding behind it, as well as the engagement during review that shapes it into something we can confidently take on long-term.
## Pull request guidelines
+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 for model conversion
# Install OpenVino Runtime and Dev library
COPY docker/main/requirements-ov.txt /requirements-ov.txt
RUN apt-get -qq update \
&& apt-get -qq install -y wget python3 python3-distutils \
&& apt-get -qq install -y wget python3 python3-dev python3-distutils gcc pkg-config libhdf5-dev \
&& 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" \
+7 -38
View File
@@ -1,42 +1,11 @@
"""Convert the default SSDLite MobileNet v2 model to OpenVINO IR.
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
from openvino.tools import mo
model = ov.convert_model(
ov_model = mo.convert_model(
"/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb",
input=[("image_tensor:0", [1, 300, 300, 3])],
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,
)
# 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)
ov.save_model(ov_model, "/models/ssdlite_mobilenet_v2.xml")
+1 -1
View File
@@ -2,7 +2,7 @@
set -euxo pipefail
SQLITE_VEC_VERSION="0.1.9"
SQLITE_VEC_VERSION="0.1.3"
source /etc/os-release
+2 -1
View File
@@ -1,2 +1,3 @@
numpy
openvino >= 2026.2.0
tensorflow
openvino-dev>=2024.0.0
+33 -15
View File
@@ -1149,6 +1149,11 @@ rknn:
| **Model Input D Type** | `int` (Frigate's default value) |
| **Object Detection Model Type** | `yolo-generic` |
yaml: |-
detectors:
rknn:
type: rknn
num_cores: 0
model: # required
# name of model (will be automatically downloaded) or path to your own .rknn model file
# possible values are:
@@ -1187,6 +1192,11 @@ rknn:
| **Model Input D Type** | `int` (Frigate's default value) |
| **Object Detection Model Type** | `yolonas` |
yaml: |-
detectors:
rknn:
type: rknn
num_cores: 0
model: # required
# name of model (will be automatically downloaded) or path to your own .rknn model file
# possible values are:
@@ -1222,6 +1232,11 @@ rknn:
| **Model Input D Type** | `int` (Frigate's default value) |
| **Object Detection Model Type** | `yolox` |
yaml: |-
detectors:
rknn:
type: rknn
num_cores: 0
model: # required
# name of model (will be automatically downloaded) or path to your own .rknn model file
# possible values are:
@@ -1297,11 +1312,12 @@ degirumAiServer:
| **Zoo** | `degirum/public` |
| **Token** | your AI Hub token (optional for the public zoo) |
yaml: |
degirum_detector:
type: degirum
location: degirum
zoo: degirum/public
token: dg_example_token
detectors:
degirum_detector:
type: degirum
location: degirum
zoo: degirum/public
token: dg_example_token
degirumLocal:
title: DeGirum Local
models:
@@ -1318,11 +1334,12 @@ degirumLocal:
| **Zoo** | `degirum/public` |
| **Token** | your AI Hub token (optional for the public zoo) |
yaml: |
degirum_detector:
type: degirum
location: @local
zoo: degirum/public
token: dg_example_token
detectors:
degirum_detector:
type: degirum
location: "@local"
zoo: degirum/public
token: dg_example_token
degirumCloud:
title: DeGirum AI Hub Cloud
models:
@@ -1339,8 +1356,9 @@ degirumCloud:
| **Zoo** | `degirum/public` |
| **Token** | your AI Hub token (optional for the public zoo) |
yaml: |
degirum_detector:
type: degirum
location: @cloud
zoo: degirum/public
token: dg_example_token
detectors:
degirum_detector:
type: degirum
location: "@cloud"
zoo: degirum/public
token: dg_example_token
+6 -10
View File
@@ -11,8 +11,6 @@ It is not recommended to copy this full configuration file. Only specify values
:::
Sections marked `# NOTE: Can be overridden at the camera level` can be set globally and then adjusted per camera. See [Global and Camera-Level Configuration](../config_overrides.md) for how that works.
```yaml
mqtt:
# Optional: Enable mqtt server (default: shown below)
@@ -173,14 +171,13 @@ model:
# Valid values are rgb, bgr, or yuv. (default: shown below)
input_pixel_format: rgb
# Required: Object detection model input tensor format
# Valid values are nhwc, nchw, hwnc, or hwcn (default: shown below)
# Valid values are nhwc or nchw (default: shown below)
input_tensor: nhwc
# Optional: Data type of the model input tensor
# Valid values are float, float_denorm, or int (default: shown below)
input_dtype: int
# Required: Object detection model architecture, used by detectors that support more
# than one model type (openvino, onnx, rknn, memryx, axengine, synaptics, and others)
# Valid values are ssd, yolox, yolonas, yolo-generic, rfdetr, dfine (default: shown below)
# Required: Object detection model type, currently only used with the OpenVINO detector
# Valid values are ssd, yolox, yolonas (default: shown below)
model_type: ssd
# Required: Label name modifications. These are merged into the standard labelmap.
labelmap:
@@ -471,8 +468,8 @@ review:
detections: False
# Optional: Activity Context Prompt to give context to the GenAI what activity is and is not suspicious.
# It is important to be direct and detailed. See documentation for the default prompt structure.
activity_context_prompt: |
Define what is and is not suspicious
activity_context_prompt: """Define what is and is not suspicious
"""
# Optional: Image source for GenAI (default: preview)
# Options: "preview" (uses cached preview frames at ~180p) or "recordings" (extracts frames from recordings at 480p)
# Using "recordings" provides better image quality but uses more tokens per image.
@@ -816,8 +813,7 @@ classification:
cameras:
camera_name:
# Required: Crop of image frame on this camera to run classification on
# [x1, y1, x2, y2] as decimals between 0 and 1, relative to the detect resolution
crop: [0.0, 0.25, 0.3, 0.85]
crop: [0, 180, 220, 400]
# Optional: If classification should be run when motion is detected in the crop (default: shown below)
motion: False
# Optional: Interval to run classification on in seconds (default: shown below)
+1 -1
View File
@@ -335,7 +335,7 @@ For example:
```
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:stable
image: blakeblackshear/frigate:latest
environment:
- FRIGATE_BASE_PATH=/frigate
```
+2 -2
View File
@@ -272,7 +272,7 @@ If you have CUDA hardware, you can experiment with the `large` `whisper` model o
#### Transcription and translation of `speech` audio events
Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button (the microphone icon) in the Tracked Object Details pane.
Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button 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 (the microphone icon) 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 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,19 +262,6 @@ 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)**
+1 -1
View File
@@ -165,7 +165,7 @@ If available, recommended settings are:
#### Setup via the Add Camera Wizard
The [Add Camera Wizard](cameras.md#adding-a-camera-with-the-add-camera-wizard) is the recommended way to add a standard Reolink camera. Before starting, make sure [HTTP is enabled](https://support.reolink.com/articles/360003452893-How-to-Access-Reolink-Cameras-NVRs-Home-Hub-Locally-via-Web-Browsers/) in the camera's advanced network settings. The wizard uses the camera's HTTP API to determine its resolution and choose the recommended stream type from the table above.
The Add Camera Wizard is the recommended way to add a standard Reolink camera. Before starting, make sure [HTTP is enabled](https://support.reolink.com/articles/360003452893-How-to-Access-Reolink-Cameras-NVRs-Home-Hub-Locally-via-Web-Browsers/) in the camera's advanced network settings. The wizard uses the camera's HTTP API to determine its resolution and choose the recommended stream type from the table above.
1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />.
2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand.
+1 -44
View File
@@ -7,49 +7,6 @@ import ConfigTabs from "@site/src/components/ConfigTabs";
import TabItem from "@theme/TabItem";
import NavPath from "@site/src/components/NavPath";
## Adding a camera with the Add Camera Wizard
The Add Camera Wizard is the recommended way to add a camera. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />. The wizard connects to your camera, tests each stream, and writes the camera's configuration for you, including the [go2rtc](go2rtc.md) restream and the live view stream mapping, so a standard setup needs no hand-written YAML.
### Step 1: Name and connection
Enter a name for the camera along with its host or IP address and credentials, then choose how the wizard should find the camera's streams:
- **Probe camera** queries the camera over ONVIF (the ONVIF port is usually 80 or 8080) and asks it for its stream URLs. Some cameras use a separate ONVIF/service account rather than the device admin user, and some require **Use digest authentication** to be enabled.
- **Manual selection** builds a stream URL from a template for the camera brand you pick (Dahua/Amcrest/EmpireTech, Hikvision/Uniview/Annke, Ubiquiti, Reolink, Axis, TP-Link, or Foscam). Choose **Other** to enter a custom RTSP URL directly. Non-RTSP stream types must be [configured manually](#setting-up-camera-inputs).
The name you enter is lowercased and spaces become underscores. If the result still isn't a valid config key, the wizard generates a safe name and stores what you typed as `friendly_name`.
### Step 2: Probe or snapshot
In probe mode, the wizard reports what the camera returned (manufacturer, model, firmware, profile count, and whether PTZ, presets, and [autotracking](autotracking.md) are supported) along with the RTSP URLs it discovered. Test each candidate to see its resolution, frame rate, and codecs together with a snapshot, then select the one you want to use.
In manual mode, the wizard tests the templated URL and shows the same metadata and snapshot.
If no RTSP URLs are found, the credentials may be wrong or the camera may not support ONVIF. Go back and use manual selection instead.
### Step 3: Stream configuration
Assign [roles](#setting-up-camera-inputs) to the stream, and use **Add Another Stream** to add the camera's other streams, for example a substream for `detect` alongside the main stream for `record`. At least one stream must have the `detect` role before you can continue.
**Reduce connections to camera** routes that input through the go2rtc restream so Frigate and the live view share a single connection to the camera instead of each opening their own. See [restream](restream.md) for more detail.
### Step 4: Validation and testing
Connect each stream to get a live preview, an estimated bandwidth figure, and a list of validation results. The wizard checks for the most common misconfigurations, including:
- A detect resolution that is too high (increased resource usage) or too low for reliable detection, or one it could not probe at all
- A stream marked `record` whose audio codec is not AAC, or that has no audio at all
- A stream marked `audio` that carries no audio stream
- Using a restreamed input for the `record` role
- Brand-specific issues, such as an RTSP stream on a Reolink camera that should use http-flv, or a Dahua/Hikvision substream selected for `detect`
**Use stream compatibility mode** passes the stream through go2rtc's ffmpeg module. Enable it if a stream fails to load after several attempts. Note that this also prevents [two way talk](/configuration/live#two-way-talk) from being detected for that stream.
**Save New Camera** writes the configuration and starts the camera right away. No restart is required.
Other features, including [hardware acceleration](hardware_acceleration_video.md), [two way talk](/configuration/live#two-way-talk), and audio transcoding, is configured after the camera has been added. For camera model specific quirks, see the [camera specific](camera_specific.md) docs.
## Setting Up Camera Inputs
Several inputs can be configured for each camera and the role of each input can be mixed and matched based on your needs. This allows you to use a lower resolution stream for object detection, but create recordings from a higher resolution stream, or vice versa.
@@ -112,7 +69,7 @@ Additional cameras are simply added under the camera configuration section.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and use the [Add Camera Wizard](#adding-a-camera-with-the-add-camera-wizard) to configure each additional camera.
Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and use the add camera button to configure each additional camera.
</TabItem>
<TabItem value="yaml">
+2 -3
View File
@@ -20,7 +20,7 @@ Settings are organized into two scopes:
- **Global configuration**: values under <NavPath path="Settings > Global configuration" /> apply to every camera by default. This is where you set the baseline behavior for object detection, recording, snapshots, motion, and so on.
- **Camera configuration**: values under <NavPath path="Settings > Camera configuration" /> apply to a single camera. Use the camera selector button at the top of these pages to choose which camera you are editing.
When a camera-level section is left untouched, the camera simply inherits the global values. Changing a value on a camera page **overrides** the global value for that camera only: the global setting and every other camera are unaffected. This mirrors how the YAML works, where a value set under `cameras.<name>` takes precedence over the same value set at the top level. See [Global and Camera-Level Configuration](./config_overrides.md) for the full details, including how lists and maps are handled and which settings must be enabled globally first.
When a camera-level section is left untouched, the camera simply inherits the global values. Changing a value on a camera page **overrides** the global value for that camera only: the global setting and every other camera are unaffected. This mirrors how the YAML works, where a value set under `cameras.<name>` takes precedence over the same value set at the top level.
To undo an override and go back to inheriting from the parent scope, use the reset button at the bottom of the section:
@@ -130,8 +130,7 @@ go2rtc:
```yaml
genai:
my_provider:
api_key: "{FRIGATE_GENAI_API_KEY}"
api_key: "{FRIGATE_GENAI_API_KEY}"
```
## Common configuration examples
-244
View File
@@ -1,244 +0,0 @@
---
id: config_overrides
title: Global and Camera-Level Configuration
---
import ConfigTabs from "@site/src/components/ConfigTabs";
import TabItem from "@theme/TabItem";
import NavPath from "@site/src/components/NavPath";
Most of Frigate's configuration can be set once for all cameras and then adjusted for individual cameras. The global value acts as the default for every camera, and any camera can override it.
This page explains how that inheritance works. For a tour of the Settings UI itself, see [Frigate Configuration](./config.md).
## The basics
Set a value globally and every camera uses it. Set the same value on a camera and that camera uses its own value instead.
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Global configuration > Object detection" /> and set **Detect FPS** to `5`. Every camera now detects at 5 fps.
2. Navigate to <NavPath path="Settings > Camera configuration > Object detection" />, select the `driveway` camera, and set **Detect FPS** to `10`.
The `driveway` camera now detects at 10 fps. Every other camera still uses the global value of 5.
</TabItem>
<TabItem value="yaml">
```yaml
detect:
fps: 5 # every camera detects at 5 fps
cameras:
front_door:
ffmpeg: ...
driveway:
ffmpeg: ...
detect:
fps: 10 # except this one
```
`front_door` inherits `fps: 5`, and `driveway` uses `10`.
</TabItem>
</ConfigTabs>
## Overrides apply per value, not per section
Overriding one value in a section does not detach the rest of that section. Everything you don't set on the camera still comes from the global configuration.
<ConfigTabs>
<TabItem value="ui">
If you set a camera's **Motion threshold** but leave **Contour area** alone, only the threshold is overridden. The contour area continues to follow <NavPath path="Settings > Global configuration > Motion detection" />, and changing it there still affects that camera.
Open a section to see which values are overridden: the section header indicates how many fields differ from the global configuration.
</TabItem>
<TabItem value="yaml">
```yaml
motion:
threshold: 30
contour_area: 10
cameras:
driveway:
motion:
threshold: 40
```
The `driveway` camera ends up with `threshold: 40` and `contour_area: 10`. Only the value you wrote was overridden.
</TabItem>
</ConfigTabs>
## Returning a camera to the global value
<ConfigTabs>
<TabItem value="ui">
A camera section that has its own values shows an **Overridden** badge. To remove the override and go back to inheriting, use the **Reset to Global** button at the bottom of the section.
</TabItem>
<TabItem value="yaml">
Frigate treats a camera value as an override because it is written in the config file, not because it differs from the global value. Repeating the global value under a camera still creates an override:
```yaml
snapshots:
enabled: true
cameras:
driveway:
snapshots:
enabled: true # this is an override, even though it matches
```
If you later change the global `snapshots.enabled` to `false`, `driveway` keeps saving snapshots, because it has its own value. To make a camera follow the global value again, delete the key from the camera rather than setting it to match.
</TabItem>
</ConfigTabs>
## Lists replace, maps merge
This is the distinction that surprises people most.
**Lists are replaced entirely.** A camera's list does not add to the global list, it takes its place.
<ConfigTabs>
<TabItem value="ui">
The camera page shows the objects the camera is currently tracking, starting from the global list. Changing that selection under <NavPath path="Settings > Camera configuration > Objects" /> replaces the list for that camera, so make sure every object you want tracked is selected, not just the ones you are adding.
</TabItem>
<TabItem value="yaml">
```yaml
objects:
track:
- person
- car
cameras:
backyard:
objects:
track:
- dog # backyard tracks ONLY dog, not person or car
```
To track `dog` in addition to the global objects, list all of them on the camera.
</TabItem>
</ConfigTabs>
An empty list is a valid override, and is the normal way to opt a camera out of something:
```yaml
review:
alerts:
labels:
- person
cameras:
street:
review:
alerts:
labels: [] # this camera never creates alerts
```
**Maps are merged key by key.** A camera can add an entry without redeclaring the others.
<ConfigTabs>
<TabItem value="ui">
Adding a filter for one object under <NavPath path="Settings > Camera configuration > Objects" /> does not remove the filters inherited from <NavPath path="Settings > Global configuration > Objects" />. The camera keeps both.
</TabItem>
<TabItem value="yaml">
```yaml
objects:
filters:
person:
min_area: 5000
cameras:
driveway:
objects:
filters:
car:
min_area: 10000
```
The `driveway` camera ends up with both the `car` filter it defined and the `person` filter from the global configuration.
</TabItem>
</ConfigTabs>
## Which settings can be overridden
Most, but not all. The [full reference config](./advanced/reference.md) is the authoritative source: sections that support camera-level overrides are marked with the comment `# NOTE: Can be overridden at the camera level`. In the UI, a setting can be overridden if it appears under both <NavPath path="Settings > Global configuration" /> and <NavPath path="Settings > Camera configuration" />.
A few things worth knowing beyond that:
- Some sections are **global only** and have no camera-level equivalent, including `go2rtc`, `genai` providers, `classification`, `telemetry`, `camera_groups`, and `ui`.
- Some sections exist **only at the camera level**, such as `zones` and `onvif`.
- Some sections are **partially overridable**, meaning a camera accepts only a few of the keys available globally. `face_recognition`, `lpr`, and `audio_transcription` work this way, and the reference config notes which keys apply.
## Enrichments that must be enabled globally first
License plate recognition and face recognition are special: the global setting is not just a default, it is a switch that must be on before any camera can use the feature. Enabling one on a camera while it is disabled globally is a configuration error, and Frigate will refuse to start:
```
Camera driveway has lpr enabled but lpr is disabled at the global level of the config. You must enable lpr at the global level.
```
Enable the feature globally, then turn it off on the cameras that don't need it.
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Global configuration > License plate recognition" /> and enable **LPR**.
2. Navigate to <NavPath path="Settings > Camera configuration > License plate recognition" />, select each camera that should not run LPR, and disable the **Enable LPR** toggle.
</TabItem>
<TabItem value="yaml">
```yaml
lpr:
enabled: true
cameras:
driveway:
ffmpeg: ... # inherits lpr, enabled
backyard:
ffmpeg: ...
lpr:
enabled: false # opted out
```
</TabItem>
</ConfigTabs>
:::note
This applies only to `lpr` and `face_recognition`, because the global setting controls whether the supporting background process starts at all. Other features do not work this way. Audio transcription, for example, can be enabled on a single camera without being enabled globally.
:::
## Profiles
[Profiles](./profiles.md) add a further layer on top of everything described above. A profile is a named set of camera overrides that you can switch on and off while Frigate is running, for example to change detection and recording behavior when you leave the house.
Profiles are applied on top of a camera's already-resolved configuration, so a profile value wins over both the camera and the global value while that profile is active. Profiles cover a subset of the camera sections and do not modify your config file.
## Summary
- A camera inherits every value you don't set on it.
- Overriding one value does not detach the rest of the section.
- Writing a value on a camera overrides it, even if it matches the global value. Remove it to inherit again.
- Lists replace the global list. Maps merge into it.
- An empty list is an override, not an omission.
- `lpr` and `face_recognition` must be enabled globally before a camera can use them.
@@ -73,13 +73,9 @@ classification:
interval: 10 # also run every N seconds (optional)
cameras:
front:
# [x1, y1, x2, y2] as decimals between 0 and 1, relative to the
# camera's detect resolution
crop: [0.0, 0.25, 0.3, 0.85]
crop: [0, 180, 220, 400]
```
Crop coordinates are normalized: each value is a fraction of the camera's `detect` width or height, not a pixel value. Drawing the crop in the UI wizard writes these values for you.
An optional config, `save_attempts`, can be set as a key under the model name. This defines the number of classification attempts to save in the Recent Classifications tab. For state classification models, the default is 100.
</TabItem>
+2 -16
View File
@@ -232,21 +232,7 @@ 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. 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.
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.
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`.
@@ -256,7 +242,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.
3. Any detected faces will then be _recognized_.
2. 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).
+52 -83
View File
@@ -9,27 +9,9 @@ import NavPath from "@site/src/components/NavPath";
## Configuration
A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 5 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI-Compatible section below.
A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 4 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI-Compatible section below.
`genai` is a map of named providers. Each key under `genai` is a name you choose, and its value is that provider's settings:
```yaml
genai:
my_provider: # any name you like
provider: ollama
base_url: http://localhost:11434
model: qwen3-vl:4b
roles:
- descriptions
- embeddings
- chat
```
The examples on this page all use `my_provider`, but the name is arbitrary and is only used to reference the provider elsewhere in the config (for example, `semantic_search.model`).
Each provider handles one or more **roles**: `chat`, `descriptions`, and `embeddings`. A provider handles all three by default, and each role may be assigned to exactly one provider. Define a single provider if you want it to do everything, or split the roles across several providers using the `roles` option.
If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`.
To use Generative AI, you must define a single provider at the global level of your Frigate configuration. If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`.
## Local Providers
@@ -96,26 +78,23 @@ 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
- Optionally, under **Provider Options**, set `context_size` to override the context size Frigate detects from the server
- Under **Provider Options**, set `context_size` to tell Frigate your context size so it can send the appropriate amount of information
</TabItem>
<TabItem value="yaml">
```yaml
genai:
my_provider:
provider: llamacpp
base_url: http://localhost:8080
model: your-model-name
provider_options:
context_size: 16000 # Optional, overrides the context size reported by the server.
provider: llamacpp
base_url: http://localhost:8080
model: your-model-name
provider_options:
context_size: 16000 # Tell Frigate your context size so it can send the appropriate amount of information.
```
</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.
@@ -148,14 +127,13 @@ Note that Frigate will not automatically download the model you specify in your
```yaml
genai:
my_provider:
provider: ollama
base_url: http://localhost:11434
model: qwen3-vl:4b
provider_options: # other Ollama client options can be defined
keep_alive: -1
options:
num_ctx: 8192 # make sure the context matches other services that are using ollama
provider: ollama
base_url: http://localhost:11434
model: qwen3-vl:4b
provider_options: # other Ollama client options can be defined
keep_alive: -1
options:
num_ctx: 8192 # make sure the context matches other services that are using ollama
```
</TabItem>
@@ -171,12 +149,11 @@ For OpenAI-compatible servers (such as llama.cpp) that don't expose the configur
```yaml
genai:
my_provider:
provider: openai
base_url: http://your-llama-server
model: your-model-name
provider_options:
context_size: 8192 # Specify the configured context size
provider: openai
base_url: http://your-llama-server
model: your-model-name
provider_options:
context_size: 8192 # Specify the configured context size
```
This ensures Frigate uses the correct context window size when generating prompts.
@@ -199,11 +176,10 @@ This ensures Frigate uses the correct context window size when generating prompt
```yaml
genai:
my_provider:
provider: openai
base_url: http://your-server:port
api_key: your-api-key # May not be required for local servers
model: your-model-name
provider: openai
base_url: http://your-server:port
api_key: your-api-key # May not be required for local servers
model: your-model-name
```
</TabItem>
@@ -241,21 +217,19 @@ Ollama also supports [cloud models](https://ollama.com/cloud), where model infer
```yaml
genai:
my_provider:
provider: ollama
base_url: http://localhost:11434
model: cloud-model-name
provider: ollama
base_url: http://localhost:11434
model: cloud-model-name
```
or when using Ollama Cloud directly
```yaml
genai:
my_provider:
provider: ollama
base_url: https://ollama.com
model: cloud-model-name
api_key: your-api-key
provider: ollama
base_url: https://ollama.com
model: cloud-model-name
api_key: your-api-key
```
</TabItem>
@@ -293,10 +267,9 @@ To start using Gemini, you must first get an API key from [Google AI Studio](htt
```yaml
genai:
my_provider:
provider: gemini
api_key: "{FRIGATE_GEMINI_API_KEY}"
model: gemini-2.5-flash
provider: gemini
api_key: "{FRIGATE_GEMINI_API_KEY}"
model: gemini-2.5-flash
```
</TabItem>
@@ -306,13 +279,12 @@ genai:
To use a different Gemini-compatible API endpoint, set the `provider_options` with the `base_url` key to your provider's API URL. For example:
```yaml {5,6}
```yaml {4,5}
genai:
my_provider:
provider: gemini
...
provider_options:
base_url: https://...
provider: gemini
...
provider_options:
base_url: https://...
```
Other HTTP options are available, see the [python-genai documentation](https://github.com/googleapis/python-genai).
@@ -346,10 +318,9 @@ To start using OpenAI, you must first [create an API key](https://platform.opena
```yaml
genai:
my_provider:
provider: openai
api_key: "{FRIGATE_OPENAI_API_KEY}"
model: gpt-4o
provider: openai
api_key: "{FRIGATE_OPENAI_API_KEY}"
model: gpt-4o
```
</TabItem>
@@ -365,14 +336,13 @@ To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` env
For OpenAI-compatible servers (such as llama.cpp) that don't expose the configured context size in the API response, you can manually specify the context size in `provider_options`:
```yaml {6,7}
```yaml {5,6}
genai:
my_provider:
provider: openai
base_url: http://your-llama-server
model: your-model-name
provider_options:
context_size: 8192 # Specify the configured context size
provider: openai
base_url: http://your-llama-server
model: your-model-name
provider_options:
context_size: 8192 # Specify the configured context size
```
This ensures Frigate uses the correct context window size when generating prompts.
@@ -407,11 +377,10 @@ To start using Azure OpenAI, you must first [create a resource](https://learn.mi
```yaml
genai:
my_provider:
provider: azure_openai
base_url: https://instance.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview
model: gpt-5-mini
api_key: "{FRIGATE_OPENAI_API_KEY}"
provider: azure_openai
base_url: https://instance.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview
model: gpt-5-mini
api_key: "{FRIGATE_OPENAI_API_KEY}"
```
</TabItem>
+3 -4
View File
@@ -52,10 +52,9 @@ You can define custom prompts at the global level and per-object type. To config
```yaml
genai:
my_provider:
provider: ollama
base_url: http://localhost:11434
model: qwen3-vl:8b-instruct
provider: ollama
base_url: http://localhost:11434
model: qwen3-vl:8b-instruct
objects:
genai:
+1 -1
View File
@@ -15,7 +15,7 @@ Frigate uses the bundled go2rtc to power a number of key features:
:::tip[Most users no longer need to configure go2rtc by hand]
The [**camera setup wizard**](cameras.md#adding-a-camera-with-the-add-camera-wizard) is the recommended way to add cameras. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />, and the wizard probes your camera and writes its configuration for you, including the go2rtc restream and the live stream mapping, so go2rtc is set up automatically.
The **camera setup wizard** is the recommended way to add cameras. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />, and the wizard probes your camera and writes its configuration for you, including the go2rtc restream and the live stream mapping, so go2rtc is set up automatically.
This guide is mainly useful if you are **upgrading from an older version and have existing cameras that don't yet use go2rtc**, or if you want to fine-tune a stream by hand (for example, to transcode a codec your browser can't play). The [go2rtc troubleshooting guide](/troubleshooting/go2rtc) applies regardless of how your cameras were added.
@@ -4,6 +4,7 @@ title: License Plate Recognition (LPR)
---
import ConfigTabs from "@site/src/components/ConfigTabs";
import FrigateConfigMock from "@site/src/components/FrigateConfigMock";
import TabItem from "@theme/TabItem";
import NavPath from "@site/src/components/NavPath";
import FaqItem from "@site/src/components/FaqItem";
@@ -50,9 +51,11 @@ License plate recognition is disabled by default and must be enabled before it c
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
- Set **Enable LPR** to on
<FrigateConfigMock
section="lpr"
values={{ enabled: true }}
targets={["enabled"]}
/>
</TabItem>
<TabItem value="yaml">
@@ -70,7 +73,17 @@ Like other enrichments in Frigate, LPR **must be enabled globally** to use the f
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Camera configuration > License plate recognition" /> for the desired camera and disable the **Enable LPR** toggle.
<FrigateConfigMock
section="lpr"
level="camera"
values={{ enabled: false }}
targets={[
{
field: "enabled",
hint: "Disable the Enable LPR toggle for this camera.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -99,16 +112,40 @@ Fine-tune the LPR feature using these optional parameters. The only optional par
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
- **Detection threshold**: License plate object detection confidence score required before recognition runs. This field only applies to the standalone license plate detection model; `threshold` and `min_score` object filters should be used for models like Frigate+ that have license plate detection built in.
- Default: `0.7`
- **Minimum plate area**: Minimum area (in pixels) a license plate must be before recognition runs. This is an _area_ measurement (length x width). For reference, 1000 pixels represents a ~32x32 pixel square in your camera image. Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates.
- Default: `1000` pixels
- **Device**: Device to use to run license plate detection _and_ recognition models. Auto-selected by Frigate and can be `CPU`, `GPU`, or the GPU's device number. For users without a model that detects license plates natively, using a GPU may increase performance of the YOLOv9 license plate detector model. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation.
- Default: `None`
- **Model size**: The size of the model used to identify regions of text on plates. The `small` model is fast and identifies groups of Latin and Chinese characters. The `large` model identifies Latin characters only, and uses an enhanced text detector to find characters on multi-line plates. If your country or region does not use multi-line plates, you should use the `small` model.
- Default: `small`
<FrigateConfigMock
showNavigationSteps={false}
section="lpr"
autoPlay={false}
values={{
enabled: true,
detection_threshold: 0.7,
min_area: 1000,
device: "CPU",
model_size: "small",
}}
targets={[
{
field: "detection_threshold",
hint:
"License plate object detection confidence score required before recognition runs. This field only applies to the standalone license plate detection model; threshold and min_score object filters should be used for models like Frigate+ that have license plate detection built in.",
},
{
field: "min_area",
hint:
"Minimum area (in pixels) a license plate must be before recognition runs. This is an area measurement (length x width). For reference, 1000 pixels represents a ~32x32 pixel square in your camera image. Depending on the resolution of your camera's detect stream, you can increase this value to ignore small or distant plates.",
},
{
field: "device",
hint:
"Device to use to run license plate detection and recognition models. Auto-selected by Frigate and can be CPU, GPU, or the GPU's device number. For users without a model that detects license plates natively, using a GPU may increase performance of the YOLOv9 license plate detector model. See the Hardware Accelerated Enrichments documentation.",
},
{
field: "model_size",
hint:
"The size of the model used to identify regions of text on plates. The small model is fast and identifies groups of Latin and Chinese characters. The large model identifies Latin characters only, and uses an enhanced text detector to find characters on multi-line plates. If your country or region does not use multi-line plates, you should use the small model.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -130,12 +167,34 @@ lpr:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
- **Recognition threshold**: Recognition confidence score required to add the plate to the object as a `recognized_license_plate` and/or `sub_label`.
- Default: `0.9`
- **Min plate length**: Minimum number of characters a detected license plate must have to be added as a `recognized_license_plate` and/or `sub_label`. Use this to filter out short, incomplete, or incorrect detections.
- **Plate format regex**: A regular expression defining the expected format of detected plates. Plates that do not match this format will be discarded. Websites like https://regex101.com/ can help test regular expressions for your plates.
<FrigateConfigMock
showNavigationSteps={false}
section="lpr"
autoPlay={false}
values={{
enabled: true,
recognition_threshold: 0.9,
min_plate_length: 4,
format: "^[A-Z]{2}[0-9]{2} [A-Z]{3}$",
}}
targets={[
{
field: "recognition_threshold",
hint:
"Recognition confidence score required to add the plate to the object as a recognized_license_plate and/or sub_label.",
},
{
field: "min_plate_length",
hint:
"Minimum number of characters a detected license plate must have to be added as a recognized_license_plate and/or sub_label. Use this to filter out short, incomplete, or incorrect detections.",
},
{
field: "format",
hint:
"A regular expression defining the expected format of detected plates. Plates that do not match this format will be discarded. Websites like https://regex101.com/ can help test regular expressions for your plates.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -156,10 +215,31 @@ lpr:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
- **Known plates**: Assign custom `sub_label` values to `car` and `motorcycle` objects when a recognized plate matches a known value. These labels appear in the UI, filters, and notifications. Unknown plates are still saved but are added to the `recognized_license_plate` field rather than the `sub_label`.
- **Match distance**: Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate. For example, setting to `1` allows a plate `ABCDE` to match `ABCBE` or `ABCD`. This parameter will _not_ operate on known plates that are defined as regular expressions.
<FrigateConfigMock
showNavigationSteps={false}
section="lpr"
autoPlay={false}
values={{
enabled: true,
match_distance: 1,
known_plates: {
"Wife's Car": ["ABC-1234"],
Johnny: ["J*N-*234"],
},
}}
targets={[
{
field: "match_distance",
hint:
"Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate. For example, setting to 1 allows a plate ABCDE to match ABCBE or ABCD. This parameter will not operate on known plates that are defined as regular expressions.",
},
{
field: "known_plates",
hint:
"Assign custom sub_label values to car and motorcycle objects when a recognized plate matches a known value. These labels appear in the UI, filters, and notifications. Unknown plates are still saved but are added to the recognized_license_plate field rather than the sub_label.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -183,10 +263,19 @@ lpr:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
- **Enhancement level**: A value between 0 and 10 that adjusts the level of image enhancement applied to captured license plates before they are processed for recognition. Higher values increase contrast, sharpen details, and reduce noise, but excessive enhancement can blur or distort characters. This setting is best adjusted at the camera level if running LPR on multiple cameras.
- Default: `0` (no enhancement)
<FrigateConfigMock
showNavigationSteps={false}
section="lpr"
autoPlay={false}
values={{ enabled: true, enhancement: 1 }}
targets={[
{
field: "enhancement",
hint:
"A value between 0 and 10 that adjusts the level of image enhancement applied to captured license plates before they are processed for recognition. Higher values increase contrast, sharpen details, and reduce noise, but excessive enhancement can blur or distort characters. This setting is best adjusted at the camera level if running LPR on multiple cameras.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -207,17 +296,27 @@ If Frigate is already recognizing plates correctly, leave enhancement at the def
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
Under **Replacement rules**, add regex rules to normalize detected plate strings before matching. Rules fire in order. For example:
| Pattern | Replacement | Description |
| ---------------- | ----------- | -------------------------------------------------- |
| `[%#*?]` | _(empty)_ | Remove noise symbols |
| `[= ]` | `-` | Normalize `=` or space to dash |
| `O` | `0` | Swap `O` to `0` (common OCR error) |
| `I` | `1` | Swap `I` to `1` |
| `(\w{3})(\w{3})` | `\1-\2` | Split 6 chars into groups (e.g., ABC123 → ABC-123) |
<FrigateConfigMock
showNavigationSteps={false}
section="lpr"
autoPlay={false}
values={{
replace_rules: [
{ pattern: "[%#*?]", replacement: "" },
{ pattern: "[= ]", replacement: "-" },
{ pattern: "O", replacement: "0" },
{ pattern: "I", replacement: "1" },
{ pattern: "(\\w{3})(\\w{3})", replacement: "\\1-\\2" },
],
}}
targets={[
{
field: "replace_rules",
hint:
"Add regex rules to normalize detected plate strings before matching. Rules fire in order.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -252,9 +351,19 @@ These rules must be defined at the global level of your `lpr` config.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
- **Save debug plates**: Set to on to save captured text on plates for debugging. These images are stored in `/media/frigate/clips/lpr`, organized into subdirectories by `<camera>/<event_id>`, and named based on the capture timestamp.
<FrigateConfigMock
showNavigationSteps={false}
section="lpr"
autoPlay={false}
values={{ enabled: true, debug_save_plates: true }}
targets={[
{
field: "debug_save_plates",
hint:
"Set to on to save captured text on plates for debugging. These images are stored in /media/frigate/clips/lpr and named based on the capture timestamp.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -279,17 +388,35 @@ These configuration parameters are available at the global level. The only optio
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
| Field | Description |
| ------------------------------ | ----------------------------------------------------------------------------------------------------- |
| **Enable LPR** | Set to on |
| **Minimum plate area** | Set to `1500` to ignore plates with an area (length x width) smaller than 1500 pixels |
| **Min plate length** | Set to `4` to only recognize plates with 4 or more characters |
| **Known plates > Wife's Car** | `ABC-1234`, `ABC-I234` (accounts for potential confusion between the number one and capital letter I) |
| **Known plates > Johnny** | `J*N-*234` (matches JHN-1234 and JMN-I234; `*` matches any number of characters) |
| **Known plates > Sally** | `[S5]LL 1234` (matches both SLL 1234 and 5LL 1234) |
| **Known plates > Work Trucks** | `EMP-[0-9]{3}[A-Z]` (matches plates like EMP-123A, EMP-456Z) |
<FrigateConfigMock
showNavigationSteps={false}
section="lpr"
autoPlay={false}
values={{
enabled: true,
min_area: 1500,
min_plate_length: 4,
known_plates: {
"Wife's Car": ["ABC-1234", "ABC-I234"],
Johnny: ["J*N-*234"],
Sally: ["[S5]LL 1234"],
"Work Trucks": ["EMP-[0-9]{3}[A-Z]"],
},
}}
targets={[
{ field: "enabled", hint: "Set to on." },
{
field: "min_area",
hint:
"Set to 1500 to ignore plates with an area (length x width) smaller than 1500 pixels.",
},
{
field: "min_plate_length",
hint: "Set to 4 to only recognize plates with 4 or more characters.",
},
"known_plates",
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -321,7 +448,18 @@ If a camera is configured to detect `car` or `motorcycle` but you don't want Fri
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Camera configuration > License plate recognition" /> for the desired camera and disable the **Enable LPR** toggle.
<FrigateConfigMock
autoPlay={false}
section="lpr"
level="camera"
values={{ enabled: false }}
targets={[
{
field: "enabled",
hint: "Disable the Enable LPR toggle for this camera.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -362,48 +500,93 @@ An example configuration for a dedicated LPR camera using a `license_plate`-dete
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" /> and set **Enable LPR** to on. Set **Device** to `CPU` (can also be `GPU` if available).
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add your camera streams.
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.
| Field | Description |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| **Enable object detection** | Set to on |
| **Detect FPS** | Set to `5`. Increase to `10` if vehicles move quickly across your frame. Higher than 10 is unnecessary and is not recommended. |
| **Minimum initialization frames** | Set to `2` |
| **Detect width** | Set to `1920` |
| **Detect height** | Set to `1080` |
Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
| Field | Description |
| ---------------------------------------------- | ------------------- |
| **Objects to track** | Add `license_plate` |
| **Object filters > License Plate > Threshold** | Set to `0.7` |
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
| Field | Description |
| -------------------- | --------------------------------------------------------------------- |
| **Motion threshold** | Set to `30` |
| **Contour area** | Set to `60`. Use an increased value to tune out small motion changes. |
| **Improve contrast** | Set to off |
Also add a motion mask over your camera's timestamp so it is not incorrectly detected as a license plate.
Navigate to <NavPath path="Settings > Camera configuration > Recording" />.
| Field | Description |
| -------------------- | -------------------------------------------------------- |
| **Enable recording** | Set to on. Disable recording if you only want snapshots. |
Navigate to <NavPath path="Settings > Camera configuration > Snapshots" />.
| Field | Description |
| -------------------- | ----------- |
| **Enable snapshots** | Set to on |
<FrigateConfigMock
section="lpr"
autoPlay={false}
steps={[
{
section: "lpr",
level: "global",
values: { enabled: true, device: "CPU" },
targets: ["enabled", "device"],
},
{
section: "detect",
level: "camera",
values: {
enabled: true,
fps: 5,
min_initialized: 2,
width: 1920,
height: 1080,
},
targets: [
{ field: "enabled", hint: "Set to on." },
{
field: "fps",
hint:
"Set to 5. Increase to 10 if vehicles move quickly across your frame. Higher than 10 is unnecessary and is not recommended.",
},
{ field: "min_initialized", hint: "Set to 2." },
{ field: "width", hint: "Set to 1920." },
{ field: "height", hint: "Set to 1080." },
],
},
{
section: "objects",
level: "camera",
values: {
track: ["license_plate"],
filters: { license_plate: { threshold: 0.7 } },
},
targets: [
{ field: "track", hint: "Add license_plate." },
"filters",
],
},
{
section: "motion",
level: "camera",
values: {
threshold: 30,
contour_area: 60,
improve_contrast: false,
},
targets: [
{ field: "threshold", hint: "Set to 30." },
{
field: "contour_area",
hint:
"Set to 60. Use an increased value to tune out small motion changes.",
},
{ field: "improve_contrast", hint: "Set to off." },
],
},
{
section: "record",
level: "camera",
values: { enabled: true },
targets: [
{
field: "enabled",
hint: "Set to on. Disable recording if you only want snapshots.",
},
],
},
{
section: "snapshots",
level: "camera",
values: { enabled: true },
targets: [{ field: "enabled", hint: "Set to on." }],
},
{
section: "review",
level: "camera",
values: { "detections.labels": ["license_plate"] },
targets: ["detections.labels"],
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -467,54 +650,104 @@ An example configuration for a dedicated LPR camera using the secondary pipeline
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" /> and set **Enable LPR** to on. Set **Device** to `CPU` (can also be `GPU` if available and the correct Docker image is used). Set **Detection threshold** to `0.7` (change if necessary).
Navigate to <NavPath path="Settings > Camera configuration > License plate recognition" /> for your dedicated LPR camera.
| Field | Description |
| --------------------- | -------------------------------------------------------------------------------- |
| **Enable LPR** | Set to on |
| **Enhancement level** | Set to `3` (optional, enhances the image before trying to recognize characters) |
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add your camera streams.
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.
| Field | Description |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| **Enable object detection** | Set to off to disable Frigate's standard object detection pipeline |
| **Detect FPS** | Set to `5`. Increase if necessary, though high values may slow down Frigate's enrichments pipeline and use considerable CPU. |
| **Detect width** | Set to `1920` (recommended value, but depends on your camera) |
| **Detect height** | Set to `1080` (recommended value, but depends on your camera) |
Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
| Field | Description |
| -------------------- | -------------------------------------------------------------------------------------- |
| **Objects to track** | Set to an empty list, required when not using a Frigate+ model for dedicated LPR mode |
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
| Field | Description |
| -------------------- | --------------------------------------------------------------------- |
| **Motion threshold** | Set to `30` |
| **Contour area** | Set to `60`. Use an increased value to tune out small motion changes. |
| **Improve contrast** | Set to off |
Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and add a motion mask over your camera's timestamp so it is not incorrectly detected as a license plate.
Navigate to <NavPath path="Settings > Camera configuration > Recording" />.
| Field | Description |
| -------------------- | -------------------------------------------------------- |
| **Enable recording** | Set to on. Disable recording if you only want snapshots. |
Navigate to <NavPath path="Settings > Camera configuration > Review" />.
| Field | Description |
| ----------------------------------------- | --------------- |
| **Detections config > Enable detections** | Set to on |
| **Detections config > Retain > Default** | Set to `7` days |
<FrigateConfigMock
steps={[
{
section: "lpr",
level: "global",
values: {
enabled: true,
device: "CPU",
detection_threshold: 0.7,
},
targets: ["enabled", "device", "detection_threshold"],
},
{
section: "lpr",
level: "camera",
values: { enabled: true, enhancement: 3 },
targets: [
{ field: "enabled", hint: "Set to on." },
{
field: "enhancement",
hint:
"Set to 3. This optional setting enhances the image before trying to recognize characters.",
},
],
},
{
section: "detect",
level: "camera",
values: { enabled: false, fps: 5, width: 1920, height: 1080 },
targets: [
{
field: "enabled",
hint: "Set to off to disable Frigate's standard object detection pipeline.",
},
{
field: "fps",
hint:
"Set to 5. Increase if necessary, though high values may slow down Frigate's enrichments pipeline and use considerable CPU.",
},
{
field: "width",
hint: "Set to 1920. The appropriate value depends on your camera.",
},
{
field: "height",
hint: "Set to 1080. The appropriate value depends on your camera.",
},
],
},
{
section: "objects",
level: "camera",
values: { track: [] },
targets: [
{
field: "track",
hint:
"Set to an empty list. This is required when not using a Frigate+ model for dedicated LPR mode.",
},
],
},
{
section: "motion",
level: "camera",
values: {
threshold: 30,
contour_area: 60,
improve_contrast: false,
},
targets: [
{ field: "threshold", hint: "Set to 30." },
{
field: "contour_area",
hint:
"Set to 60. Use an increased value to tune out small motion changes.",
},
{ field: "improve_contrast", hint: "Set to off." },
],
},
{
section: "record",
level: "camera",
values: { enabled: true },
targets: [
{
field: "enabled",
hint: "Set to on. Disable recording if you only want snapshots.",
},
],
},
{
section: "review",
level: "camera",
values: { "detections.enabled": true },
targets: ["detections.enabled"],
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -655,11 +888,13 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
- Set **Enable LPR** to on
- Set **Device** to `CPU`
- Set **Save debug plates** to on
<FrigateConfigMock
autoPlay={false}
section="lpr"
showNavigationSteps={false}
values={{ enabled: true, device: "CPU", debug_save_plates: true }}
targets={["enabled", "device", "debug_save_plates"]}
/>
</TabItem>
<TabItem value="yaml">
+2 -2
View File
@@ -34,7 +34,7 @@ If you are using go2rtc, you should adjust the following settings in your camera
- Video codec: **H.264** - provides the most compatible video codec with all Live view technologies and browsers. Avoid any kind of "smart codec" or "+" codec like _H.264+_ or _H.265+_. as these non-standard codecs remove keyframes (see below).
- Audio codec: **AAC** - provides the most compatible audio codec with all Live view technologies and browsers that support audio.
- I-frame interval (sometimes called the keyframe interval, the interframe space, or the GOP length): match your camera's frame rate, or choose "1x" (for interframe space on Reolink cameras). For example, if your stream outputs 20fps, your i-frame interval should be 20 (or 1x on Reolink). Values higher than the frame rate will cause the stream to take longer to begin playback. See [this page](https://web.archive.org/web/20251213190836/https://gardinal.net/understanding-the-keyframe-interval/) for more on keyframes. For many users this may not be an issue, but it should be noted that a 1x i-frame interval will cause more storage utilization if you are using the stream for the `record` role as well.
- I-frame interval (sometimes called the keyframe interval, the interframe space, or the GOP length): match your camera's frame rate, or choose "1x" (for interframe space on Reolink cameras). For example, if your stream outputs 20fps, your i-frame interval should be 20 (or 1x on Reolink). Values higher than the frame rate will cause the stream to take longer to begin playback. See [this page](https://gardinal.net/understanding-the-keyframe-interval/) for more on keyframes. For many users this may not be an issue, but it should be noted that a 1x i-frame interval will cause more storage utilization if you are using the stream for the `record` role as well.
The default video and audio codec on your camera may not always be compatible with your browser, which is why setting them to H.264 and AAC is recommended. See the [go2rtc docs](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#codecs-madness) for codec support information.
@@ -196,7 +196,7 @@ services:
:::
See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.14#module-webrtc) for more information about this.
See [go2rtc WebRTC docs](https://github.com/AlexxIT/go2rtc/tree/v1.8.3#module-webrtc) for more information about this.
### Two way talk
+53 -8
View File
@@ -5,7 +5,7 @@ title: Masks
import ConfigTabs from "@site/src/components/ConfigTabs";
import TabItem from "@theme/TabItem";
import NavPath from "@site/src/components/NavPath";
import FrigateConfigMock from "@site/src/components/FrigateConfigMock";
Frigate has two kinds of masks: motion masks and object filter masks. Both are narrow tools for fine-tuning, **not for hiding an area from Frigate**. Masks should be used sparingly; in most cases where users reach for one, a [zone](zones.md) with [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones) is the right tool instead. See [Which tool do I need?](#which-tool-do-i-need) and [Common mistakes](#common-mistakes) below if you're new to Frigate's mask behavior.
@@ -25,19 +25,64 @@ Object filter masks can be used to filter out stubborn false positives in fixed
## Which tool do I need?
| What you're trying to do | Recommended tool | How it works |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Only get alerts/detections for activity in the areas you care about, ignoring activity elsewhere (e.g., alert when someone enters your yard, but not when they walk past on the sidewalk) | A [zone](zones.md) combined with [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones) | Frigate keeps detecting and tracking activity everywhere in the frame, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. |
| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. |
| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. |
| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. |
| What you're trying to do | Recommended tool | How it works |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Only get alerts/detections for activity in the areas you care about, ignoring activity elsewhere (e.g., alert when someone enters your yard, but not when they walk past on the sidewalk) | A [zone](zones.md) combined with [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones) | Frigate keeps detecting and tracking activity everywhere in the frame, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. |
| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. |
| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. |
| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. |
## Using the mask creator
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select a camera. Use the mask editor to draw motion masks and object filter masks directly on the camera feed. Each mask can be given a friendly name and toggled on or off.
<FrigateConfigMock
level="camera"
section="masksAndZones"
steps={[
{
focus: "motionMasks",
label: "Motion Mask",
hint: "Motion masks prevent unwanted motion from triggering detection. Use them sparingly so object tracking is not disrupted.",
},
{
focus: "motionMask.add",
label: "New Motion Mask",
hint: "Select the plus button beside Motion Mask to create a mask.",
},
{
focus: "motionMask.canvas",
label: "Draw the motion mask",
hint: "Select points on the camera image, then close the polygon by selecting the first point again.",
},
{
focus: "motionMask.options",
label: "Motion mask options",
hint: "Give the mask a friendly name and choose whether it is enabled.",
},
{
focus: "objectMasks",
label: "Object Masks",
hint: "Object masks filter false positives according to the bottom center of an object's bounding box.",
},
{
focus: "objectMask.add",
label: "New Object Mask",
hint: "Select the plus button beside Object Masks to create an object filter mask.",
},
{
focus: "objectMask.canvas",
label: "Draw the object mask",
hint: "Draw a precise polygon over the fixed location that produces false positives.",
},
{
focus: "objectMask.options",
label: "Object mask options",
hint: "Name the mask, select the object type it applies to, and save it.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
+8 -7
View File
@@ -4,6 +4,7 @@ title: Motion Detection
---
import ConfigTabs from "@site/src/components/ConfigTabs";
import FrigateConfigMock from "@site/src/components/FrigateConfigMock";
import TabItem from "@theme/TabItem";
import NavPath from "@site/src/components/NavPath";
@@ -44,13 +45,13 @@ The threshold value dictates how much of a change in a pixels luminance is requi
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > Motion detection" /> to set the threshold globally.
To override for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Motion detection" /> and select the camera, or use the <NavPath path="Settings > Camera configuration > Motion tuner" /> to adjust it live.
| Field | Description |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Motion threshold** | The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive. The value should be between 1 and 255. (default: 30) |
<FrigateConfigMock
section="motion"
fields={["threshold"]}
values={{ threshold: 30 }}
focus="threshold"
hint="The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive. The value should be between 1 and 255. (default: 30)"
/>
</TabItem>
<TabItem value="yaml">
+91 -6
View File
@@ -5,6 +5,7 @@ title: Object Detectors
import CommunityBadge from '@site/src/components/CommunityBadge';
import ConfigTabs from '@site/src/components/ConfigTabs';
import FrigateConfigMock from '@site/src/components/FrigateConfigMock';
import TabItem from '@theme/TabItem';
import NavPath from '@site/src/components/NavPath';
import ModelConfigDropdown from '@site/src/components/ModelConfigDropdown';
@@ -112,7 +113,17 @@ See [common Edge TPU troubleshooting steps](/troubleshooting/edgetpu) if the Edg
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.
<FrigateConfigMock
autoPlay={false}
section="model"
values={{ detectors: { coral: { type: "edgetpu", device: "usb" } } }}
targets={[
{
field: "detectors",
hint: "Add an EdgeTPU detector named coral and set its device to usb.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -132,7 +143,24 @@ detectors:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `usb:0` and `usb:1` as the device for each.
<FrigateConfigMock
autoPlay={false}
showNavigationSteps={false}
section="model"
values={{
detectors: {
coral1: { type: "edgetpu", device: "usb:0" },
coral2: { type: "edgetpu", device: "usb:1" },
},
}}
targets={[
{
field: "detectors",
hint:
"Add two EdgeTPU detectors and assign usb:0 and usb:1 as their devices.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -157,7 +185,19 @@ _warning: may have [compatibility issues](https://github.com/blakeblackshear/fri
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then leave the device field empty.
<FrigateConfigMock
autoPlay={false}
showNavigationSteps={false}
section="model"
values={{ detectors: { coral: { type: "edgetpu", device: "" } } }}
targets={[
{
field: "detectors",
hint:
"Add an EdgeTPU detector and leave Device empty so Frigate auto-detects the native Coral.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -177,7 +217,18 @@ detectors:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `pci`.
<FrigateConfigMock
autoPlay={false}
showNavigationSteps={false}
section="model"
values={{ detectors: { coral: { type: "edgetpu", device: "pci" } } }}
targets={[
{
field: "detectors",
hint: "Add an EdgeTPU detector named coral and set its device to pci.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -197,7 +248,24 @@ detectors:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `pci:0` and `pci:1` as the device for each.
<FrigateConfigMock
autoPlay={false}
showNavigationSteps={false}
section="model"
values={{
detectors: {
coral1: { type: "edgetpu", device: "pci:0" },
coral2: { type: "edgetpu", device: "pci:1" },
},
}}
targets={[
{
field: "detectors",
hint:
"Add two EdgeTPU detectors and assign pci:0 and pci:1 as their devices.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -220,7 +288,24 @@ detectors:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors with different device types (e.g., `usb` and `pci`).
<FrigateConfigMock
autoPlay={false}
showNavigationSteps={false}
section="model"
values={{
detectors: {
coral_usb: { type: "edgetpu", device: "usb" },
coral_pci: { type: "edgetpu", device: "pci" },
},
}}
targets={[
{
field: "detectors",
hint:
"Add two EdgeTPU detectors and assign usb to one device and pci to the other.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
-15
View File
@@ -232,21 +232,6 @@ 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.
+2 -2
View File
@@ -163,8 +163,8 @@ genai:
model: your-model-name
roles:
- embeddings
- descriptions
- chat
- vision
- tools
semantic_search:
enabled: True
+134 -48
View File
@@ -5,7 +5,7 @@ title: Zones
import ConfigTabs from "@site/src/components/ConfigTabs";
import TabItem from "@theme/TabItem";
import NavPath from "@site/src/components/NavPath";
import FrigateConfigMock from "@site/src/components/FrigateConfigMock";
Zones allow you to define a specific area of the frame and apply additional filters for object types so you can determine whether or not an object is within a particular area. Presence in a zone is evaluated based on the bottom center of the bounding box for the object. It does not matter how much of the bounding box overlaps with the zone.
@@ -25,11 +25,32 @@ During testing, enable the Zones option for the [Debug view](/usage/live#the-sin
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Under the **Zones** section, click the plus icon to add a new zone.
3. Click on the camera's latest image to create the points for the zone boundary. Click the first point again to close the polygon.
4. Configure zone options such as **Friendly name**, **Objects**, **Loitering time**, and **Inertia** in the zone editor.
5. Press **Save** when finished.
<FrigateConfigMock
level="camera"
section="masksAndZones"
steps={[
{
focus: "zone.add",
label: "Add Zone",
hint: "Select the plus button beside Zones to create a zone.",
},
{
focus: "zone.canvas",
label: "Draw the zone",
hint: "Select points on the camera image, then close the polygon by selecting the first point again.",
},
{
focus: "zone.options",
label: "Zone options",
hint: "Configure the friendly name, objects, loitering time, inertia, and optional speed settings.",
},
{
focus: "zone.save",
label: "Save",
hint: "Save the zone after its boundary and options are complete.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -57,11 +78,14 @@ To create an alert only when an object enters the `entire_yard` zone:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Camera configuration > Review" />.
| Field | Description |
| ---------------------------------- | ----------------------------------------------------------------------------------------- |
| **Alerts config > Required zones** | Set to `entire_yard` so an object must enter that zone to be considered an alert; leave empty to allow alerts anywhere in the frame. |
<FrigateConfigMock
autoPlay={false}
level="camera"
section="review"
focus="alerts.required_zones"
values={{ "alerts.required_zones": ["entire_yard"] }}
hint="Select entire_yard so an object must enter that zone to be considered an alert."
/>
</TabItem>
<TabItem value="yaml">
@@ -87,12 +111,26 @@ You may also want to filter detections to only be created when an object enters
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Camera configuration > Review" />.
| Field | Description |
| -------------------------------------- | -------------------------------------------------------------------------------------------- |
| **Alerts config > Required zones** | Set to `inner_yard` so an object must enter that zone to be considered an alert; leave empty to allow alerts anywhere in the frame. |
| **Detections config > Required zones** | Set to `edge_yard` so an object must enter that zone to be considered a detection; leave empty to allow detections anywhere in the frame. |
<FrigateConfigMock
autoPlay={false}
showNavigationSteps={false}
level="camera"
section="review"
values={{
"alerts.required_zones": ["inner_yard"],
"detections.required_zones": ["edge_yard"],
}}
targets={[
{
field: "alerts.required_zones",
hint: "Select inner_yard so an object must enter the inner area to be considered an alert.",
},
{
field: "detections.required_zones",
hint: "Select edge_yard so activity in the secondary area can be retained as a detection.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -126,8 +164,15 @@ To only save snapshots when an object enters a specific zone, for example an `en
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Snapshots" /> and select your camera.
- Set **Required zones** to `entire_yard`
<FrigateConfigMock
autoPlay={false}
showNavigationSteps={false}
level="camera"
section="snapshots"
focus="required_zones"
values={{ required_zones: ["entire_yard"] }}
hint="Select entire_yard to save snapshots only after an object enters that zone."
/>
</TabItem>
<TabItem value="yaml">
@@ -154,11 +199,15 @@ Sometimes you want to limit a zone to specific object types to have more granula
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Create a zone named `entire_yard` covering everywhere you want to track a person.
- Under **Objects**, add `person`
3. Create a second zone named `front_yard_street` covering just the street.
- Under **Objects**, add `car`
<FrigateConfigMock
autoPlay={false}
showNavigationSteps={false}
level="camera"
section="masksAndZones"
focus="zone.objects"
label="Objects"
hint="Choose which object types apply to the zone. For example, use person for the yard and car for the street."
/>
</TabItem>
<TabItem value="yaml">
@@ -198,10 +247,15 @@ When using loitering zones, a review item will behave in the following way:
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Edit or create the zone (e.g., `sidewalk`).
- Set **Loitering time** to the desired number of seconds (e.g., `4`)
- Under **Objects**, add the relevant object types (e.g., `person`)
<FrigateConfigMock
autoPlay={false}
showNavigationSteps={false}
level="camera"
section="masksAndZones"
focus="zone.loitering_time"
label="Loitering Time"
hint="Set the minimum number of seconds an object must remain in the zone before the zone activates."
/>
</TabItem>
<TabItem value="yaml">
@@ -227,9 +281,15 @@ Sometimes an objects bounding box may be slightly incorrect and the bottom cente
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Edit or create the zone (e.g., `front_yard`).
- Set **Inertia** to the desired number of consecutive frames (e.g., `3`)
<FrigateConfigMock
autoPlay={false}
showNavigationSteps={false}
level="camera"
section="masksAndZones"
focus="zone.inertia"
label="Inertia"
hint="Set the number of consecutive frames an object must be inside the zone. The default is 3."
/>
</TabItem>
<TabItem value="yaml">
@@ -253,9 +313,15 @@ There may also be cases where you expect an object to quickly enter and exit a z
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Edit or create the zone (e.g., `driveway_entrance`).
- Set **Inertia** to `1`
<FrigateConfigMock
autoPlay={false}
showNavigationSteps={false}
level="camera"
section="masksAndZones"
focus="zone.inertia"
label="Inertia"
hint="Set Inertia to 1 when an object should be considered inside the zone immediately."
/>
</TabItem>
<TabItem value="yaml">
@@ -289,11 +355,23 @@ Accurate real-world distance measurements are required to estimate speeds. These
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Create or edit a zone with exactly 4 points aligned to the ground plane.
3. In the zone editor, enter the real-world **Distances** between each pair of consecutive points.
- For example, if the distance between the first and second points is 10 meters, between the second and third is 12 meters, etc.
4. Distances are measured in meters (metric) or feet (imperial), depending on the **Unit system** setting.
<FrigateConfigMock
level="camera"
showNavigationSteps={false}
section="masksAndZones"
steps={[
{
focus: "zone.canvas",
label: "Draw a four-point zone",
hint: "Draw exactly four points aligned to the ground plane where objects will travel.",
},
{
focus: "zone.speed",
label: "Speed Estimation",
hint: "Enter the real-world distance between each pair of consecutive points. Units follow the UI unit system setting.",
},
]}
/>
</TabItem>
<TabItem value="yaml">
@@ -317,11 +395,14 @@ The `distance` values are measured in meters (metric) or feet (imperial), depend
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > UI" />.
| Field | Description |
| --------------- | -------------------------------------------------------------------- |
| **Unit system** | Set to `metric` (kilometers per hour) or `imperial` (miles per hour) |
<FrigateConfigMock
autoPlay={false}
level="global"
section="ui"
focus="unit_system"
values={{ unit_system: "metric" }}
hint="Choose metric for kilometers per hour or imperial for miles per hour."
/>
</TabItem>
<TabItem value="yaml">
@@ -356,10 +437,15 @@ Zones can be configured with a minimum speed requirement, meaning an object must
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Edit or create the zone with distances configured.
- Set **Speed threshold** to the desired minimum speed (e.g., `20`)
- The unit is kph or mph, depending on the **Unit system** setting
<FrigateConfigMock
autoPlay={false}
showNavigationSteps={false}
level="camera"
section="masksAndZones"
focus="zone.speed_threshold"
label="Speed Threshold"
hint="Set the minimum speed required for an object to be considered inside the zone. Units follow the UI unit system setting."
/>
</TabItem>
<TabItem value="yaml">
+2 -25
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). If raising the shm size does not help, check your [process and file limits](#process-and-file-limits) as well.
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 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,30 +86,6 @@ 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.
@@ -508,6 +484,7 @@ Generate a Frigate Docker Compose configuration based on your hardware and requi
<DockerComposeGenerator/>
</TabItem>
<TabItem value="original" label="Example Docker Compose File">
```yaml
+1 -1
View File
@@ -144,7 +144,7 @@ At this point you should be able to start Frigate and a basic config will be cre
### Step 2: Add a camera
Click the **Add Camera** button in <NavPath path="Settings > Global configuration > Camera management" /> to use the camera setup wizard to get your first camera added into Frigate. See [Adding a camera with the Add Camera Wizard](../configuration/cameras.md#adding-a-camera-with-the-add-camera-wizard) for a walkthrough of each step.
Click the **Add Camera** button in <NavPath path="Settings > Global configuration > Camera management" /> to use the camera setup wizard to get your first camera added into Frigate.
### Step 3: Configure hardware acceleration (recommended)
+1 -1
View File
@@ -281,7 +281,7 @@ For advanced usecases, this behavior can be changed with the [RTSP URL
template](#options) option. When set, this string will override the default stream
address that is derived from the default behavior described above. This option supports
[jinja2 templates](https://jinja.palletsprojects.com/) and has the `camera` dict
variables from [Frigate API](/integrations/api/frigate-http-api)
variables from [Frigate API](../integrations/api)
available for the template. Note that no Home Assistant state is available to the
template, only the camera dict from Frigate.
+1 -1
View File
@@ -16,7 +16,7 @@ MQTT requires a network connection to your broker. This is typically local, but
### `frigate/available`
Designed to be used as an availability topic with Home Assistant. Possible message are:
"online": published once Frigate is running and has published its initial state. Note that this is published on every connection to the broker, so it is republished if the broker restarts or the connection drops and recovers, without Frigate itself restarting.
"online": published when Frigate is running (on startup)
"stopped": published when Frigate is stopped normally
"offline": published automatically by the MQTT broker if Frigate disconnects unexpectedly (via MQTT Will Message)
-234
View File
@@ -1,234 +0,0 @@
---
id: common_errors
title: Common Error Messages
---
import FaqItem from "@site/src/components/FaqItem";
This page is an index of error messages you might see in Frigate's logs, what each one means, and where to go next. It is organized by the kind of problem, not by which component logged the message.
Two things to know before you start:
- **Many of these messages come from FFmpeg, go2rtc, GPU drivers, or the operating system, not from Frigate itself.** Frigate captures and re-logs their output, so the log level shown in the Frigate UI does not always reflect the original severity.
- **Wrapped errors put the real cause on the next line.** When Frigate logs a generic message like `Error occurred when attempting to maintain recording cache`, the actual exception is logged immediately after it. When a camera's FFmpeg process exits, Frigate logs `The following ffmpeg logs include the last 100 lines prior to exit` and dumps that camera's FFmpeg output. Always read those lines, they are where the answer usually is.
## Camera connection and streams
<FaqItem id="connection-refused-no-route-to-host-401-404" question="Connection refused / No route to host / 401 Unauthorized / 404 Not Found">
These are FFmpeg errors about reaching the camera (or the go2rtc restream). `Connection refused` and `No route to host` mean nothing is listening at that address or the host is unreachable; `401 Unauthorized` is wrong credentials; `404 Not Found` is a wrong stream path (or a `restream` input pointing at a go2rtc stream name that does not exist). A camera that has hit its concurrent-connection limit can also return `refused` or `401` on a URL that works in VLC.
See [go2rtc troubleshooting](/troubleshooting/go2rtc#1-read-the-go2rtc-logs) for how to isolate the stream.
</FaqItem>
<FaqItem id="no-frames-received-in-20-seconds" question="No frames received from <camera> in 20 seconds. Exiting ffmpeg...">
FFmpeg is running but has stopped delivering video for 20 seconds, so Frigate's camera watchdog restarts it. The stream connected at least once, then went quiet: a camera reboot, a network drop, the camera evicting the connection, or a stalled decoder. If it repeats on a loop, the stream is unstable.
</FaqItem>
<FaqItem id="ffmpeg-process-crashed-unexpectedly" question="Ffmpeg process crashed unexpectedly for <camera>">
The detect FFmpeg process exited on its own. This message is only the notification; the cause is in the 100 FFmpeg log lines Frigate dumps right after it (look for a `Failed to sync surface`, `Connection refused`, codec, or audio error in that block). Related watchdog messages include `<camera> exceeded fps limit`, which means the camera is delivering frames faster than `detect.fps` (usually a camera whose real frame rate differs from what is configured).
</FaqItem>
<FaqItem id="non-monotonically-increasing-dts" question="Application provided invalid, non monotonically increasing dts to muxer">
An FFmpeg message meaning the camera sent packets with out-of-order timestamps. Because recordings are copied without re-encoding, FFmpeg cannot fix them, and the segment muxer often splits early, producing one-second segments and a cache backlog. The usual cause is a camera "Smart Codec" / H.264+ / H.265+ mode or a camera clock that jumps.
See [Recordings: segments are only 1 second long](/troubleshooting/recordings#segments-are-only-1-second-long).
</FaqItem>
<FaqItem id="bad-cseq" question="RTP: PT=xx: bad cseq (packet loss / reordering)">
An FFmpeg message meaning RTP packets arrived out of sequence, which almost always means the stream is using UDP transport. Frigate's RTSP presets force TCP, so seeing this points at a custom `input_args`, `preset-rtsp-udp`, or a go2rtc source that is not using TCP. Switch to TCP unless your camera is [UDP-only](/configuration/camera_specific#udp-only-cameras).
</FaqItem>
<FaqItem id="error-while-decoding-mb-non-existing-pps" question="error while decoding MB / non-existing PPS referenced (corrupt frames)">
FFmpeg decoder messages meaning the received video bitstream was incomplete or damaged. A few of these at every stream start are normal (the decoder connected before the first keyframe) and Frigate discards them. A continuous stream of them means real packet loss, from Wi-Fi or a saturated link, an overloaded camera, or an FFmpeg restart loop caused by another problem. Fix the underlying instability rather than the message.
</FaqItem>
<FaqItem id="could-not-find-codec-parameters" question="Could not find codec parameters for stream ... unspecified size">
An FFmpeg message meaning it probed the stream but never saw enough decodable video to determine the frame size, often because the probe window ended before the first keyframe on a long-GOP stream, or because the stream is not delivering usable video. If it is a Reolink HTTP stream, use `preset-http-reolink`, which raises the probe size for exactly this case.
</FaqItem>
## Recording
<FaqItem id="no-new-recording-segments" question="No new recording segments were created for <camera> in the last 120s">
Frigate's record watchdog is restarting the record FFmpeg process because no valid segment has reached the cache. This means the record stream is not connecting or the segments are being rejected (see the audio-codec entry below).
See [Recordings: the record stream isn't connecting](/troubleshooting/recordings#the-record-stream-isnt-connecting).
</FaqItem>
<FaqItem id="invalid-or-missing-video-stream-in-segment" question="Invalid or missing video stream in segment. Discarding.">
A cached recording segment failed validation (no readable video stream) and was deleted. The most common cause is a segment that was truncated because the record FFmpeg process was killed mid-write, so this often appears alongside, and as a consequence of, the record-stream restarts above. A segment containing only audio triggers it too.
</FaqItem>
<FaqItem id="incompatible-audio-codec" question="Recordings silently fail to save (incompatible audio codec)">
Some camera audio codecs (G.711 variants such as `pcm_alaw` and `pcm_mulaw`) cannot be stored in an MP4 container, so segments never finalize even though live view works.
See [Recordings: incompatible audio codec](/troubleshooting/recordings#incompatible-audio-codec-recordings-silently-fail-to-save) for the FFmpeg preset that transcodes the audio to AAC.
</FaqItem>
<FaqItem id="error-maintaining-recording-cache" question="Error occurred when attempting to maintain recording cache">
A generic wrapper; the real exception is on the next log line. Frequently it is `[Errno 28] No space left on device` or `[Errno 17] File exists` on a network share.
See [Recordings cache warnings and errors](/troubleshooting/recordings#i-see-the-message-error--error-occurred-when-attempting-to-maintain-recording-cache), which covers this message and the common `Errno` cases.
</FaqItem>
## Hardware acceleration
<FaqItem id="failed-to-sync-surface" question="Failed to sync surface / Failed to download frame: -5 / Error while filtering">
A VAAPI/QSV hardware frame-sync failure between FFmpeg and the GPU driver, not a Frigate bug. It usually appears when the detect stream is being scaled or decoded on the GPU.
See [GPU: Failed to download frame: -5](/troubleshooting/gpu#failed-to-download-frame--5), which lists the fixes in order (switch VAAPI/QSV preset, change `LIBVA_DRIVER_NAME`, use an H.264 substream, match detect resolution and fps to the stream).
</FaqItem>
<FaqItem id="no-decoder-surfaces-left" question="No decoder surfaces left / Can't allocate a surface">
Both mean the GPU ran out of decode surfaces: `No decoder surfaces left` is NVIDIA NVDEC, `Can't allocate a surface` is Intel QSV. This is surface-pool exhaustion, typically from too many concurrent hardware-decoded cameras on one GPU (consumer NVIDIA cards have a driver-enforced limit on simultaneous decode sessions). Reduce the number of cameras decoding on that GPU, decode some on the CPU, or move to hardware without the session cap.
</FaqItem>
<FaqItem id="nvidia-container-cli-nvml-error" question="nvidia-container-cli: nvml error: driver not loaded">
This comes from the NVIDIA container runtime while starting the container, not from Frigate, and the container never starts. The NVIDIA driver is not loaded on the host. Confirm `nvidia-smi` works on the host itself (not inside the container) before troubleshooting Frigate. In a VM or LXC, the driver must be available inside the guest. See [Hardware: Nvidia GPU](/configuration/hardware_acceleration_video).
</FaqItem>
## Detectors and models
<FaqItem id="illegal-instruction" question="Illegal instruction (core dumped)">
The process was killed by the CPU for executing an unsupported instruction. There are two distinct causes in Frigate:
- **A Coral EdgeTPU** on a newer kernel with an outdated gasket driver. See [EdgeTPU: Illegal instruction](/troubleshooting/edgetpu#attempting-to-load-tpu-as-pci--fatal-python-error-illegal-instruction).
- **A CPU without AVX/AVX2**, when enabling semantic search, face recognition, license plate recognition, classification, or audio transcription. These features use libraries compiled with AVX and crash immediately on CPUs that lack it (commonly Intel Celeron/Pentium before the 2020 Tiger Lake generation). See the [CPU requirements](/frigate/planning_setup#cpu).
</FaqItem>
<FaqItem id="onnx-invalidprotobuf" question="ONNX Runtime InvalidProtobuf / failed to load model">
ONNX Runtime could not parse the model file. The file exists but its contents are not a valid ONNX model, usually a corrupted or interrupted download in `model_cache`, or the wrong file pointed at by `model.path`. Delete the cached model file so Frigate re-downloads it, and confirm `model.path` points at an actual `.onnx` model. See [ONNX detector configuration](/configuration/object_detectors#onnx).
</FaqItem>
<FaqItem id="cuda-failure-999-901" question="CUDA failure 999 / CUDA failure 901">
ONNX Runtime CUDA errors. `999` (`cudaErrorUnknown`) is a general, unrecoverable CUDA context failure, usually a driver/runtime version mismatch between the host and the container or a GPU in a bad state. `901` is a CUDA-graph capture error, which points at a custom model whose operations are not capture-safe. For `999`, align the host driver with the container's CUDA version and confirm the GPU is healthy.
</FaqItem>
<FaqItem id="openvino-no-supported-devices" question="Can't get OPTIMIZATION_CAPABILITIES property as no supported devices found">
OpenVINO could not find the configured device (usually `GPU` or `NPU`). Most often the `/dev/dri` render node is not passed into the container, or the wrong render node is mapped when an iGPU and a discrete GPU coexist.
See [GPU: no supported devices found](/troubleshooting/gpu#cant-get-optimization_capabilities-property-as-no-supported-devices-found).
</FaqItem>
## Memory and storage
<FaqItem id="fatal-python-error-bus-error" question="Fatal Python error: Bus error">
Frigate ran out of shared memory (`/dev/shm`). The container's `shm_size` is too small for the number and resolution of your detect streams, or you added cameras after startup without increasing it.
See [Calculating required shm-size](/frigate/installation#calculating-required-shm-size). If you cannot increase `shm_size`, lowering the `SHM_MAX_FRAMES` environment variable reduces how many frames Frigate buffers per camera.
</FaqItem>
<FaqItem id="errno-28-no-space-left" question="[Errno 28] No space left on device">
A filesystem is full: the recordings volume (`/media/frigate`), the cache tmpfs (`/tmp/cache`), or `/dev/shm`. Check which one, and note that inode exhaustion can produce this while `df -h` still shows free space.
See [Recordings: No space left on device](/troubleshooting/recordings#i-see-the-message-error--error-occurred-when-attempting-to-maintain-recording-cache).
</FaqItem>
<FaqItem id="container-exits-with-no-logs" question="The container exits or restarts with no error in the logs">
A silent exit is usually the host or container out-of-memory killer. Because `/dev/shm` and `/tmp/cache` are memory-backed, they count against the container's memory limit, so aggressive shm or cache sizing can trigger it. Give the container more memory, or reduce shm/cache sizing, and check the host's OOM messages (`dmesg`).
</FaqItem>
## Database
<FaqItem id="database-is-locked" question="database is locked">
SQLite could not acquire the write lock. Frigate's timeout already scales with camera count, so under normal local-disk operation this essentially only happens when the database is on a network share (SMB/NFS), where file locking is unreliable, or when two instances point at the same file.
See [Database is locked](/troubleshooting/faqs#error-database-is-locked).
</FaqItem>
<FaqItem id="database-disk-image-is-malformed" question="database disk image is malformed">
The SQLite database file is corrupted, typically after hard power loss, a network-share database, or a filesystem with unsafe write semantics. Frigate does not repair it automatically, but the database can usually be recovered by hand.
**Stop Frigate first**, then work on the database file directly (by default `/config/frigate.db`). Start by checking what is actually wrong:
```bash
sqlite3 frigate.db "PRAGMA integrity_check;"
```
If the only problems reported are index-related (lines such as `row 14 missing from index recordings_path` or `non-unique entry in index ...`), rebuilding the indexes is usually enough and is the least destructive fix:
```bash
sqlite3 frigate.db "REINDEX;"
```
If the integrity check reports page or byte-level corruption instead (for example `Multiple uses for byte 2706 of page 142272`), dump the readable contents into a new database:
```bash
# dump what can still be read
sqlite3 frigate.db .dump > frigate.dump
# keep the corrupt file, then rebuild from the dump
mv frigate.db frigate.db.bak
cat frigate.dump | sqlite3 frigate.db
# confirm the rebuilt database is clean, this should print "ok"
sqlite3 frigate.db "PRAGMA integrity_check;"
```
Rows stored in the corrupted pages cannot be recovered, so expect to lose some tracked objects, review items, or thumbnails. Recordings themselves are files on disk and are not affected.
As a last resort, stop Frigate, delete `frigate.db`, and restart. Frigate recreates it, but existing recordings lose all of their metadata. If a `backup.db` exists next to your database, Frigate wrote it before the last schema migration and restoring it recovers everything up to that point.
Repeat corruption usually points at the underlying storage: move the database off a network share, and on Raspberry Pi check power delivery and the SD card or SSD.
</FaqItem>
## Startup and web access
<FaqItem id="unable-to-start-frigate-in-safe-mode" question="Unable to start Frigate in safe mode / Starting Frigate in safe mode">
When your config fails validation at startup, Frigate prints the validation errors (with line numbers), then starts in **safe mode**: a minimal configuration with no cameras and MQTT disabled, so the UI stays reachable. In safe mode the only available page is the Config Editor, which shows the validation errors so you can fix them, then save and restart. Note that recording retention and storage cleanup do **not** run while in safe mode, so do not leave a low-disk system sitting in it.
`Unable to start Frigate in safe mode` means even the minimal config failed, which points at an error in your `auth`, `proxy`, or `database` section, or a config file that is not valid YAML at all. Safe mode is not sticky; fix the config and restart and Frigate returns to normal.
</FaqItem>
<FaqItem id="502-bad-gateway" question="502 Bad Gateway / connection refused to 127.0.0.1:5001">
The web server is up but the Frigate backend (port 5001) is not answering yet. By far the most common reason is that the page was loaded during startup: the API binds last, after database migrations (which can take minutes on a large database), model downloads, and process startup, while the web server is already serving. Wait for startup to finish. If it persists, the backend has failed to start, and the reason is earlier in the logs. This also explains a `connection refused to 127.0.0.1:5001` seen while loading `/ws`, because every authenticated request first makes an auth subrequest to that port.
</FaqItem>
-16
View File
@@ -428,19 +428,3 @@ 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). To download multiple exports as a zip archive, add them to a **case** and use the Download button there.
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).
## Cases
+12 -1
View File
@@ -19,10 +19,12 @@
"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",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-icons": "^5.7.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.7.0",
@@ -18761,6 +18763,15 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-icons": {
"version": "5.7.0",
"resolved": "https://registry.npmmirror.com/react-icons/-/react-icons-5.7.0.tgz",
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+6 -3
View File
@@ -4,9 +4,11 @@
"private": true,
"scripts": {
"build:config": "node scripts/build-config.mjs",
"build:mock": "node scripts/generate-mock-manifest.mjs",
"check:mock": "node scripts/generate-mock-manifest.mjs --check",
"docusaurus": "docusaurus",
"start": "npm run build:config && npm run regen-docs && docusaurus start --host 0.0.0.0",
"build": "npm run build:config && npm run regen-docs && docusaurus build",
"start": "npm run build:config && npm run build:mock && npm run regen-docs && docusaurus start --host 0.0.0.0",
"build": "npm run build:config && npm run build:mock && npm run regen-docs && docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
@@ -33,7 +35,8 @@
"prism-react-renderer": "^2.4.1",
"raw-loader": "^4.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-icons": "^5.7.0"
},
"browserslist": {
"production": [
+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/env node
/** Build the compact field catalog used by documentation config mocks. */
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "../..");
const schemaPath = path.join(
repoRoot,
"web/e2e/fixtures/mock-data/config-schema.json",
);
const localeRoot = path.join(repoRoot, "web/public/locales/en/config");
const sectionConfigRoot = path.join(
repoRoot,
"web/src/components/config-form/section-configs",
);
const settingsSourcePath = path.join(repoRoot, "web/src/pages/Settings.tsx");
const settingsLocalePath = path.join(
repoRoot,
"web/public/locales/en/views/settings.json",
);
const outputPath = path.join(
repoRoot,
"docs/src/components/FrigateConfigMock/manifest.json",
);
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
const translations = {
global: JSON.parse(
fs.readFileSync(path.join(localeRoot, "global.json"), "utf8"),
),
camera: JSON.parse(
fs.readFileSync(path.join(localeRoot, "cameras.json"), "utf8"),
),
groups: JSON.parse(
fs.readFileSync(path.join(localeRoot, "groups.json"), "utf8"),
),
};
const settingsTranslations = JSON.parse(
fs.readFileSync(settingsLocalePath, "utf8"),
);
function resolveNode(node) {
if (!node || typeof node !== "object") return {};
if (node.$ref) {
const refName = node.$ref.split("/").at(-1);
return {
...resolveNode(schema.$defs?.[refName]),
...node,
$ref: undefined,
};
}
const variants = node.anyOf ?? node.oneOf;
if (Array.isArray(variants)) {
const concrete = variants.find((variant) => variant.type !== "null");
return {
...resolveNode(concrete),
...node,
anyOf: undefined,
oneOf: undefined,
};
}
return node;
}
function translationAt(level, section, fieldPath) {
let current = translations[level]?.[section];
for (const segment of fieldPath) {
if (!current || typeof current !== "object") return {};
current = current[segment];
}
return current && typeof current === "object" ? current : {};
}
function inferWidget(node) {
if (Array.isArray(node.enum)) return "select";
if (node.type === "boolean") return "switch";
if (
["integer", "number"].includes(node.type) &&
node.minimum !== undefined &&
(node.maximum !== undefined || node.exclusiveMaximum !== undefined)
) {
return "range";
}
if (node.type === "integer" || node.type === "number") return "number";
if (node.type === "array") return "tags";
if (node.type === "object") return "object";
return "text";
}
function extractArray(source, key) {
const match = source.match(new RegExp(`${key}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
return match
? [...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1])
: [];
}
function extractObjectBlock(source, key) {
const match = new RegExp(`\\b${key}\\s*:\\s*\\{`).exec(source);
if (!match) return "";
const start = source.indexOf("{", match.index);
let depth = 0;
let quote = null;
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === quote) quote = null;
continue;
}
if (['"', "'", "`"].includes(character)) {
quote = character;
continue;
}
if (character === "{") depth += 1;
if (character === "}") {
depth -= 1;
if (depth === 0) return source.slice(start + 1, index);
}
}
return "";
}
function extractGroups(source) {
const fieldGroups = {};
const groupsBlock = extractObjectBlock(source, "fieldGroups");
for (const match of groupsBlock.matchAll(/(\w+)\s*:\s*\[([\s\S]*?)\]/g)) {
fieldGroups[match[1]] = [...match[2].matchAll(/["']([^"']+)["']/g)].map(
(item) => item[1],
);
}
return fieldGroups;
}
function loadSectionHints(section, level) {
const configPath = path.join(sectionConfigRoot, `${section}.ts`);
if (!fs.existsSync(configPath)) return {};
const source = fs.readFileSync(configPath, "utf8");
const base = extractObjectBlock(source, "base");
const override = extractObjectBlock(source, level);
const overrideHas = (key) => new RegExp(`\\b${key}\\s*:`).test(override);
return {
order: overrideHas("fieldOrder")
? extractArray(override, "fieldOrder")
: extractArray(base, "fieldOrder"),
hidden: [
...extractArray(base, "hiddenFields"),
...extractArray(override, "hiddenFields"),
],
advanced: overrideHas("advancedFields")
? extractArray(override, "advancedFields")
: extractArray(base, "advancedFields"),
groups: overrideHas("fieldGroups")
? extractGroups(override)
: extractGroups(base),
docs: base.match(/sectionDocs\s*:\s*["']([^"']+)["']/)?.[1] ?? null,
};
}
function groupLabel(level, section, group) {
const domain = level === "camera" ? "cameras" : "global";
return (
translations.groups?.[section]?.[domain]?.[group] ??
group.replaceAll("_", " ").replace(/^./, (value) => value.toUpperCase())
);
}
function collectFields(level, section, sectionNode, hints) {
const fields = {};
function visit(rawNode, fieldPath = []) {
const node = resolveNode(rawNode);
const properties = node.properties;
if (properties && typeof properties === "object") {
for (const [name, child] of Object.entries(properties)) {
visit(child, [...fieldPath, name]);
}
return;
}
if (fieldPath.length === 0) return;
const key = fieldPath.join(".");
const localized = translationAt(level, section, fieldPath);
fields[key] = {
label: localized.label ?? node.title ?? fieldPath.at(-1),
description: localized.description ?? node.description ?? "",
widget: inferWidget(node),
default: node.default ?? null,
enum: node.enum ?? null,
minimum: node.minimum ?? node.exclusiveMinimum ?? null,
maximum: node.maximum ?? node.exclusiveMaximum ?? null,
advanced: hints.advanced?.includes(key) ?? false,
};
}
visit(sectionNode);
return fields;
}
function buildLevel(level) {
const rootProperties =
level === "camera"
? resolveNode(schema.$defs.CameraConfig).properties
: schema.properties;
const result = {};
for (const [section, rawNode] of Object.entries(rootProperties ?? {})) {
const node = resolveNode(rawNode);
if (!node.properties) continue;
const hints = loadSectionHints(section, level);
const hidden = new Set(hints.hidden ?? []);
const fields = collectFields(level, section, node, hints);
for (const key of hidden) delete fields[key];
const localized = translations[level]?.[section] ?? {};
result[section] = {
label: localized.label ?? rawNode.title ?? node.title ?? section,
description: localized.description ?? rawNode.description ?? "",
order: hints.order ?? [],
groups: Object.entries(hints.groups ?? {}).map(([key, groupFields]) => ({
key,
label: groupLabel(level, section, key),
fields: groupFields,
})),
docs: hints.docs ?? null,
fields,
};
}
return result;
}
function parseSectionMapping(source, constantName, level) {
const match = source.match(
new RegExp(`const ${constantName}[^=]*=\\s*\\{([\\s\\S]*?)\\n\\};`),
);
if (!match) return [];
return [...match[1].matchAll(/(\w+)\s*:\s*"([^"]+)"/g)].map(
([, section, page]) => ({ section, page, level }),
);
}
function buildNavigation() {
const source = fs.readFileSync(settingsSourcePath, "utf8");
const settingsBlock = source.match(
/const settingsGroups\s*=\s*\[([\s\S]*?)\n\];/,
)?.[1];
if (!settingsBlock) return { groups: [], pages: {} };
const mappings = [
...parseSectionMapping(source, "GLOBAL_SECTION_MAPPING", "global"),
...parseSectionMapping(source, "CAMERA_SECTION_MAPPING", "camera"),
...parseSectionMapping(source, "ENRICHMENTS_SECTION_MAPPING", "global"),
...parseSectionMapping(source, "SYSTEM_SECTION_MAPPING", "global"),
];
const pages = Object.fromEntries(
mappings.map((mapping) => [mapping.page, mapping]),
);
const groupMatches = [...settingsBlock.matchAll(/\{\s*label:\s*"([^"]+)"/g)];
const groups = groupMatches.map((match, index) => {
const start = match.index ?? 0;
const end = groupMatches[index + 1]?.index ?? settingsBlock.length;
const sourceSlice = settingsBlock.slice(start, end);
const itemKeys = [...sourceSlice.matchAll(/key:\s*"([^"]+)"/g)].map(
(item) => item[1],
);
return {
key: match[1],
label: settingsTranslations.menu?.[match[1]] ?? match[1],
items: itemKeys.map((key) => ({
key,
label: settingsTranslations.menu?.[key] ?? key,
...(key === "masksAndZones"
? { section: key, page: key, level: "camera" }
: {}),
...(pages[key] ?? {}),
})),
};
});
return { groups, pages };
}
function buildDetectorTypes() {
const detectorTranslations = translations.global?.detectors ?? {};
const reserved = new Set([
"label",
"description",
"type",
"model",
"model_path",
]);
return Object.fromEntries(
Object.entries(detectorTranslations)
.filter(
([key, value]) =>
!reserved.has(key) &&
value &&
typeof value === "object" &&
typeof value.label === "string" &&
typeof value.description === "string",
)
.map(([type, value]) => [
type,
{
label: value.label,
description: value.description,
fields: Object.fromEntries(
Object.entries(value)
.filter(
([key, field]) =>
!["label", "description"].includes(key) &&
field &&
typeof field === "object" &&
typeof field.label === "string",
)
.map(([key, field]) => [
key,
{
label: field.label,
description: field.description ?? "",
},
]),
),
},
]),
);
}
const manifest = {
generatedFrom: path.relative(repoRoot, schemaPath).replaceAll("\\", "/"),
detectorTypes: buildDetectorTypes(),
levels: {
global: buildLevel("global"),
camera: buildLevel("camera"),
},
navigation: buildNavigation(),
};
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
if (process.argv.includes("--check")) {
const current = fs.existsSync(outputPath)
? fs.readFileSync(outputPath, "utf8")
: "";
if (current !== serialized) {
console.error(
`${path.relative(repoRoot, outputPath)} is stale. Run npm run build:mock.`,
);
process.exit(1);
}
console.log(`Checked ${path.relative(repoRoot, outputPath)}`);
} else {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, serialized);
console.log(`Generated ${path.relative(repoRoot, outputPath)}`);
}
+28 -8
View File
@@ -45,7 +45,7 @@ from lib.i18n_loader import load_i18n
from lib.nav_map import ALL_CONFIG_SECTIONS
from lib.schema_loader import load_schema
from lib.section_config_parser import load_section_configs
from lib.ui_generator import generate_ui_content, wrap_with_config_tabs
from lib.ui_generator import generate_mock_content, generate_ui_content, wrap_with_config_tabs
from lib.yaml_extractor import (
extract_config_tabs_blocks,
extract_yaml_blocks,
@@ -60,6 +60,7 @@ def process_file(
inject: bool = False,
verbose: bool = False,
outpath: Path | None = None,
mock: bool = False,
) -> dict:
"""Process a single markdown file for initial injection of bare YAML blocks.
@@ -114,7 +115,8 @@ def process_file(
continue
# Generate UI content
ui_content = generate_ui_content(
generator = generate_mock_content if mock else generate_ui_content
ui_content = generator(
block, schema, i18n, section_configs
)
@@ -188,6 +190,7 @@ def regenerate_file(
dry_run: bool = False,
verbose: bool = False,
outpath: Path | None = None,
mock: bool = False,
) -> dict:
"""Regenerate UI tabs in existing ConfigTabs blocks.
@@ -233,7 +236,8 @@ def regenerate_file(
continue
# Generate fresh UI content
new_ui = generate_ui_content(
generator = generate_mock_content if mock else generate_ui_content
new_ui = generator(
yaml_block, schema, i18n, section_configs
)
@@ -302,6 +306,7 @@ def check_file(
i18n: dict,
section_configs: dict,
verbose: bool = False,
mock: bool = False,
) -> dict:
"""Check for drift between existing UI tabs and what would be generated.
@@ -333,7 +338,8 @@ def check_file(
stats["skipped"] += 1
continue
new_ui = generate_ui_content(
generator = generate_mock_content if mock else generate_ui_content
new_ui = generator(
yaml_block, schema, i18n, section_configs
)
@@ -406,6 +412,10 @@ def _ensure_imports(content: str) -> str:
needed_imports.append(
'import NavPath from "@site/src/components/NavPath";'
)
if "<FrigateConfigMock" in content and 'import FrigateConfigMock' not in content:
needed_imports.append(
'import FrigateConfigMock from "@site/src/components/FrigateConfigMock";'
)
if not needed_imports:
return content
@@ -472,6 +482,11 @@ def main():
action="store_true",
help="Show detailed warnings and diagnostics",
)
parser.add_argument(
"--mock",
action="store_true",
help="Generate focused Frigate UI mocks instead of text instructions",
)
args = parser.parse_args()
# Collect files and determine base directory for relative path computation
@@ -525,23 +540,25 @@ def main():
print(f"Processing {len(files)} file(s)...\n", file=sys.stderr)
if args.check:
_run_check(files, schema, i18n, section_configs, args.verbose)
_run_check(files, schema, i18n, section_configs, args.verbose, args.mock)
elif args.regenerate:
_run_regenerate(
files, schema, i18n, section_configs,
args.dry_run, args.verbose, file_outpaths,
args.mock,
)
else:
_run_inject(
files, schema, i18n, section_configs,
args.inject, args.verbose, file_outpaths,
args.mock,
)
if outdir is not None:
print(f"\nOutput written to: {outdir}", file=sys.stderr)
def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outpaths):
def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outpaths, mock):
"""Run default mode: preview or inject bare YAML blocks."""
total_stats = {
"files": 0,
@@ -557,6 +574,7 @@ def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outp
filepath, schema, i18n, section_configs,
inject=inject, verbose=verbose,
outpath=file_outpaths.get(filepath),
mock=mock,
)
total_stats["files"] += 1
@@ -580,7 +598,7 @@ def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outp
print("=" * 60, file=sys.stderr)
def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file_outpaths):
def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file_outpaths, mock):
"""Run regenerate mode: update existing ConfigTabs blocks."""
total_stats = {
"files": 0,
@@ -595,6 +613,7 @@ def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file
filepath, schema, i18n, section_configs,
dry_run=dry_run, verbose=verbose,
outpath=file_outpaths.get(filepath),
mock=mock,
)
total_stats["files"] += 1
@@ -617,7 +636,7 @@ def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file
print("=" * 60, file=sys.stderr)
def _run_check(files, schema, i18n, section_configs, verbose):
def _run_check(files, schema, i18n, section_configs, verbose, mock):
"""Run check mode: detect drift without modifying files."""
total_stats = {
"files": 0,
@@ -630,6 +649,7 @@ def _run_check(files, schema, i18n, section_configs, verbose):
for filepath in files:
stats = check_file(
filepath, schema, i18n, section_configs, verbose=verbose,
mock=mock,
)
total_stats["files"] += 1
+76 -1
View File
@@ -1,5 +1,6 @@
"""Generate UI tab markdown content from parsed YAML blocks."""
"""Generate UI tab content from parsed YAML blocks."""
import json
from typing import Any
from .i18n_loader import get_field_description, get_field_label, get_value_label
@@ -9,6 +10,80 @@ from .section_config_parser import get_hidden_fields
from .yaml_extractor import YamlBlock, get_leaf_paths
def generate_mock_content(
block: YamlBlock,
schema: dict[str, Any],
i18n: dict[str, Any],
section_configs: dict[str, dict[str, Any]],
) -> str | None:
"""Generate a focused Frigate config mock for a YAML block."""
if block.section_key is None:
return None
if block.is_camera_level:
cameras = block.parsed.get("cameras", {})
camera_name = block.camera_name or next(iter(cameras), None)
if not camera_name or not isinstance(cameras.get(camera_name), dict):
return None
config = cameras[camera_name]
level = "camera"
else:
config = block.parsed
level = detect_level(block.section_key)
if level not in ("global", "camera"):
level = "global"
steps: list[dict[str, object]] = []
for section, section_data in config.items():
if section not in ALL_CONFIG_SECTIONS or not isinstance(
section_data, dict
):
continue
hidden = get_hidden_fields(section_configs, section, level)
values: dict[str, object] = {}
for path, value in get_leaf_paths(section_data):
path_parts = list(path)
if not _is_hidden(path_parts[-1], path_parts, hidden):
values[".".join(path_parts)] = value
if values:
steps.append(
{
"section": section,
"level": level,
"fields": list(values),
"values": values,
"focus": next(iter(values)),
}
)
if not steps:
return None
if len(steps) == 1:
step = steps[0]
return "\n".join(
[
"<FrigateConfigMock",
f' section="{step["section"]}"',
f' level="{step["level"]}"',
f" fields={{{json.dumps(step['fields'])}}}",
f" values={{{json.dumps(step['values'])}}}",
f' focus="{step["focus"]}"',
"/>",
]
)
return "\n".join(
[
"<FrigateConfigMock",
f" steps={{{json.dumps(steps)}}}",
"/>",
]
)
def _format_value(
value: object,
field_schema: dict[str, Any] | None,
+99
View File
@@ -0,0 +1,99 @@
"""Tests for focused Frigate configuration mock generation."""
import sys
import types
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
try:
import yaml # noqa: F401
except ModuleNotFoundError:
yaml_stub = types.ModuleType("yaml")
yaml_stub.YAMLError = ValueError
yaml_stub.safe_load = lambda _value: {}
sys.modules["yaml"] = yaml_stub
from lib.ui_generator import generate_mock_content
from lib.yaml_extractor import YamlBlock
def make_block(parsed: dict, section: str, camera: bool = False) -> YamlBlock:
"""Create a parsed YAML block for generator tests."""
return YamlBlock(
raw="",
parsed=parsed,
line_start=1,
line_end=1,
highlight=None,
has_comments=False,
inside_config_tabs=False,
section_key=section,
is_camera_level=camera,
camera_name="front_door" if camera else None,
config_keys=list(parsed),
)
class TestGenerateMockContent(unittest.TestCase):
def test_generates_focused_global_section(self):
content = generate_mock_content(
make_block({"motion": {"threshold": 30}}, "motion"),
{},
{},
{},
)
self.assertIn('section="motion"', content)
self.assertIn('level="global"', content)
self.assertIn('fields={["threshold"]}', content)
self.assertIn('values={{"threshold": 30}}', content)
self.assertIn('focus="threshold"', content)
def test_unwraps_camera_and_omits_hidden_fields(self):
content = generate_mock_content(
make_block(
{
"cameras": {
"front_door": {
"motion": {
"threshold": 20,
"raw_mask": "ignored",
}
}
}
},
"motion",
camera=True,
),
{},
{},
{"motion": {"hiddenFields": ["raw_mask"]}},
)
self.assertIn('level="camera"', content)
self.assertIn('fields={["threshold"]}', content)
self.assertNotIn("raw_mask", content)
def test_generates_steps_for_multiple_sections(self):
content = generate_mock_content(
make_block(
{
"record": {"enabled": True},
"snapshots": {"enabled": True},
},
"record",
),
{},
{},
{},
)
self.assertIn("steps={", content)
self.assertIn('"section": "record"', content)
self.assertIn('"section": "snapshots"', content)
if __name__ == "__main__":
unittest.main()
-2
View File
@@ -30,7 +30,6 @@ const sidebars: SidebarsConfig = {
],
Configuration: [
"configuration/config",
"configuration/config_overrides",
{
type: "category",
label: "Detectors",
@@ -166,7 +165,6 @@ const sidebars: SidebarsConfig = {
],
Troubleshooting: [
"troubleshooting/faqs",
"troubleshooting/common_errors",
"troubleshooting/go2rtc",
"troubleshooting/recordings",
"troubleshooting/dummy-camera",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,9 @@
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import CodeBlock from "@theme/CodeBlock";
import ConfigTabs from "@site/src/components/ConfigTabs";
import FrigateConfigMock from "@site/src/components/FrigateConfigMock";
import TabItem from "@theme/TabItem";
import { load } from "js-yaml";
import { marked } from "marked";
import styles from "./styles.module.css";
@@ -103,6 +105,40 @@ export default function ModelConfigDropdown({ models }) {
const selectedModel = models[selectedModelIndex];
const hasChoices = models.length > 1;
const mockConfig = useMemo(() => {
try {
const parsed = load(selectedModel.yaml) ?? {};
const model = parsed.model ?? {};
const legacyDetectors = Object.fromEntries(
Object.entries(parsed).filter(
([key, value]) =>
key !== "model" &&
value &&
typeof value === "object" &&
typeof value.type === "string",
),
);
const detectors = parsed.detectors ?? legacyDetectors;
const values = {
detectors,
...model,
};
const targets = [
...(Object.keys(detectors).length ? ["detectors"] : []),
...(Object.keys(model).length
? [
{
field: "custom_model",
hint: `Configure the custom detection model, input size, and input format for ${selectedModel.label}.`,
},
]
: []),
];
return { targets, values };
} catch {
return { targets: ["detectors"], values: { detectors: {} } };
}
}, [selectedModel.label, selectedModel.yaml]);
const handleModelSelect = (index) => {
setSelectedModelIndex(index);
@@ -158,7 +194,13 @@ export default function ModelConfigDropdown({ models }) {
<h4 className={styles.stepTitle}>Step 3 — Configure the detector</h4>
<ConfigTabs>
<TabItem value="ui">
<Markdown>{selectedModel.ui}</Markdown>
<FrigateConfigMock
autoPlay={false}
key={selectedModel.key}
section="model"
targets={mockConfig.targets}
values={mockConfig.values}
/>
</TabItem>
<TabItem value="yaml">
<CodeBlock language="yaml">{selectedModel.yaml}</CodeBlock>
-5
View File
@@ -8244,11 +8244,6 @@ 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,7 +31,6 @@ 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,
@@ -196,7 +195,7 @@ def genai_models(request: Request):
"before saving the configuration."
),
)
async def genai_probe(request: Request, body: GenAIProbeBody):
async def genai_probe(body: GenAIProbeBody):
load_providers()
provider_cls = PROVIDERS.get(body.provider)
@@ -206,13 +205,6 @@ async def genai_probe(request: Request, 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
@@ -224,7 +216,7 @@ async def genai_probe(request: Request, body: GenAIProbeBody):
try:
transient_cfg = GenAIConfig(
provider=body.provider,
api_key=api_key,
api_key=body.api_key,
base_url=body.base_url,
provider_options=probe_provider_options,
# model is required by the schema but irrelevant for listing.
@@ -923,7 +915,19 @@ def config_set(request: Request, body: AppConfigSetBody):
if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config
swap_runtime_config(request.app, 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
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
@@ -967,11 +971,7 @@ def config_set(request: Request, body: AppConfigSetBody):
content=(
{
"success": True,
"message": (
"Config successfully updated"
if body.requires_restart == 0
else "Config successfully updated, restart to apply"
),
"message": "Config successfully updated, restart to apply",
}
),
status_code=200,
+3 -9
View File
@@ -25,7 +25,6 @@ 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
@@ -1255,14 +1254,9 @@ async def delete_camera(
status_code=500,
)
# 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)
# Update runtime config
request.app.frigate_config = config
request.app.genai_manager.update_config(config)
# Publish removal to stop ffmpeg processes and clean up runtime state
request.app.config_publisher.publish_update(
-35
View File
@@ -1,35 +0,0 @@
"""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,7 +14,6 @@ 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)
+10 -15
View File
@@ -1538,18 +1538,15 @@ async def set_description(
event.data["description"] = new_description
event.save()
context: EmbeddingsContext | None = request.app.embeddings
if context is not None:
# If semantic search is enabled, update the index
if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings
if len(new_description) > 0:
# If semantic search is enabled, update the index
if request.app.frigate_config.semantic_search.enabled:
context.update_description(
event_id,
new_description,
)
context.update_description(
event_id,
new_description,
)
else:
# embeddings are always cleaned up so they don't outlive their description
context.db.delete_embeddings_description(event_ids=[event_id])
response_message = (
@@ -1678,11 +1675,9 @@ async def delete_single_event(event_id: str, request: Request) -> dict:
event.delete_instance()
Timeline.delete().where(Timeline.source_id == event_id).execute()
# embeddings are always cleaned up, even when semantic search is disabled,
# so that they don't outlive their events
context: EmbeddingsContext | None = request.app.embeddings
if context is not None:
# If semantic search is enabled, update the index
if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings
context.db.delete_embeddings_thumbnail(event_ids=[event_id])
context.db.delete_embeddings_description(event_ids=[event_id])
+1 -1
View File
@@ -270,7 +270,7 @@ class FrigateApp:
10
* len([c for c in self.config.cameras.values() if c.enabled_in_config]),
),
load_vec_extension=True,
load_vec_extension=self.config.semantic_search.enabled,
)
models = [
Event,
+1 -3
View File
@@ -117,9 +117,7 @@ class CameraMaintainer(threading.Thread):
if runtime:
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
self.ptz_metrics[name] = PTZMetrics(
autotracker_enabled=config.onvif.autotracking.enabled
)
self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False)
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.get(
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init[
self.name
)
]
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
self.name
]
+7 -87
View File
@@ -404,64 +404,38 @@ class Dispatcher:
for comm in self.comms:
comm.stop()
def apply_runtime_state(self) -> dict[str, dict[str, bool]]:
def restore_runtime_state(self) -> None:
"""Replay persisted runtime overrides through the camera settings handlers.
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.
Called once after Frigate startup completes so processing threads can
receive the resulting ``config_updater`` broadcasts. Unknown cameras
and topics are skipped; handler exceptions are logged and replay
continues for remaining entries.
"""
state = self._runtime_state.load()
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 apply runtime state %s.%s=%s",
"Failed to restore 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,
"ON" if value else "OFF",
payload,
)
def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
@@ -484,56 +458,6 @@ 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
@@ -664,10 +588,6 @@ 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,25 +96,6 @@ 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,6 +23,7 @@ from frigate.const import (
EXPIRE_AUDIO_ACTIVITY,
INSERT_MANY_RECORDINGS,
INSERT_PREVIEW,
NOTIFICATION_TEST,
REQUEST_REGION_GRID,
UPDATE_AUDIO_ACTIVITY,
UPDATE_AUDIO_TRANSCRIPTION_STATE,
@@ -56,6 +57,7 @@ _WS_BLOCKED_TOPICS = frozenset(
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_BIRDSEYE_LAYOUT,
UPDATE_AUDIO_TRANSCRIPTION_STATE,
NOTIFICATION_TEST,
}
)
-3
View File
@@ -14,7 +14,6 @@ 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"
@@ -146,8 +145,6 @@ 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:
+1 -1
View File
@@ -640,7 +640,7 @@ class FrigateConfig(FrigateBaseModel):
# set notifications state
self.notifications.enabled_in_config = self.notifications.enabled
# validate genai: each role (chat, descriptions, embeddings) at most once
# validate genai: each role (tools, vision, embeddings) at most once
role_to_name: dict[GenAIRoleEnum, str] = {}
for name, genai_cfg in self.genai.items():
for role in genai_cfg.roles:
+4 -5
View File
@@ -141,11 +141,6 @@ 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
@@ -169,6 +164,10 @@ 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,10 +288,6 @@ 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:
+6 -35
View File
@@ -1,12 +1,9 @@
import logging
import sqlite3
from typing import Any
import regex
from playhouse.sqliteq import SqliteQueueDatabase
logger = logging.getLogger(__name__)
REGEXP_TIMEOUT_SECONDS = 1.0
@@ -31,14 +28,8 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
def _load_vec_extension(self, conn: sqlite3.Connection) -> None:
conn.enable_load_extension(True)
try:
conn.load_extension(self.sqlite_vec_path)
except conn.OperationalError:
logger.error("Unable to load the sqlite-vec extension")
self.load_vec_extension = False
finally:
conn.enable_load_extension(False)
conn.load_extension(self.sqlite_vec_path)
conn.enable_load_extension(False)
def _register_regexp(self, conn: sqlite3.Connection) -> None:
def regexp(expr: str, item: str | None) -> bool:
@@ -53,33 +44,13 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
conn.create_function("REGEXP", 2, regexp)
def _delete_embeddings(self, table: str, event_ids: list[str]) -> None:
"""Delete embeddings for the given events, if the table exists.
Embeddings outlive the events they belong to when semantic search is
disabled, so deletes are attempted regardless of the current config.
"""
if not event_ids or not self.load_vec_extension:
return
# the embeddings tables are only created once semantic search has run
cursor = self.execute_sql(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
)
if cursor.fetchone() is None:
logger.debug("Skipping %s cleanup, table does not exist", table)
return
ids = ",".join(["?" for _ in event_ids])
self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
self._delete_embeddings("vec_thumbnails", event_ids)
ids = ",".join(["?" for _ in event_ids])
self.execute_sql(f"DELETE FROM vec_thumbnails WHERE id IN ({ids})", event_ids)
def delete_embeddings_description(self, event_ids: list[str]) -> None:
self._delete_embeddings("vec_descriptions", event_ids)
ids = ",".join(["?" for _ in event_ids])
self.execute_sql(f"DELETE FROM vec_descriptions WHERE id IN ({ids})", event_ids)
def drop_embeddings_tables(self) -> None:
self.execute_sql("""
+1 -1
View File
@@ -93,7 +93,7 @@ class ModelConfig(BaseModel):
model_type: ModelTypeEnum = Field(
default=ModelTypeEnum.ssd,
title="Object Detection Model Type",
description="Detector model architecture type (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine) used by some detectors for optimization.",
description="Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization.",
)
_merged_labelmap: dict[int, str] | None = PrivateAttr()
_colormap: dict[int, tuple[int, int, int]] = PrivateAttr()
+4 -5
View File
@@ -366,10 +366,9 @@ class EventCleanup(threading.Thread):
logger.debug(f"Deleting {len(chunk)} events from the database")
Event.delete().where(Event.id << chunk).execute()
# embeddings are always cleaned up, even when semantic search
# is disabled, so that they don't outlive their events
self.db.delete_embeddings_description(event_ids=chunk)
self.db.delete_embeddings_thumbnail(event_ids=chunk)
logger.debug(f"Deleted {len(chunk)} embeddings")
if self.config.semantic_search.enabled:
self.db.delete_embeddings_description(event_ids=chunk)
self.db.delete_embeddings_thumbnail(event_ids=chunk)
logger.debug(f"Deleted {len(ids_to_delete)} embeddings")
logger.info("Exiting event cleanup...")
+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.get_context_size(),
self._context_size or "unknown",
self._supports_vision,
self._supports_audio,
self._supports_tools,
+8 -18
View File
@@ -335,7 +335,6 @@ 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:
@@ -373,13 +372,6 @@ 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
@@ -490,7 +482,6 @@ 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
@@ -509,21 +500,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
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,
),
)
if len(active_cameras) == 1:
# show single camera as fullscreen
camera = active_cameras_to_add[0]
@@ -789,7 +780,6 @@ 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
-2
View File
@@ -159,8 +159,6 @@ class FFMpegConverter(threading.Thread):
f"duration {self.frame_times[t_idx + 1] - self.frame_times[t_idx]}"
)
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
try:
p = sp.run(
self.ffmpeg_cmd.split(" "),
+4 -46
View File
@@ -20,10 +20,6 @@ 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,
@@ -198,9 +194,7 @@ class PtzAutoTrackerThread(threading.Thread):
def run(self):
while not self.stop_event.wait(1):
self.ptz_autotracker.check_for_updates()
for camera, camera_config in list(self.config.cameras.items()):
for camera, camera_config in self.config.cameras.items():
if not camera_config.enabled:
continue
@@ -217,7 +211,6 @@ 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...")
@@ -251,16 +244,6 @@ 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:
@@ -277,29 +260,6 @@ 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")
@@ -1405,7 +1365,7 @@ class PtzAutoTracker:
camera_config = self.config.cameras[camera]
if camera_config.onvif.autotracking.enabled:
if not self.autotracker_init.get(camera):
if not self.autotracker_init[camera]:
future = asyncio.run_coroutine_threadsafe(
self._autotracker_setup(camera_config, camera), self.onvif.loop
)
@@ -1523,11 +1483,9 @@ class PtzAutoTracker:
}
async def camera_maintenance(self, camera):
# 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
# bail and don't check anything if we're calibrating or tracking an object
if (
not self.autotracker_init.get(camera)
not self.autotracker_init[camera]
or self.calibrating[camera]
or self.tracked_object[camera] is not None
):
+9 -10
View File
@@ -344,17 +344,16 @@ class OnvifController:
autotracking_config.enabled_in_config and autotracking_config.enabled
)
# 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
# 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
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 (
+4 -18
View File
@@ -115,11 +115,9 @@ class PendingReviewSegment:
if self._frame is not None:
self.thumb_time = datetime.datetime.now().timestamp()
self.has_frame = True
Path(self.frame_path).parent.mkdir(parents=True, exist_ok=True)
if not cv2.imwrite(
cv2.imwrite(
self.frame_path, self._frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]
):
logger.error("Failed to write review thumbnail to %s", self.frame_path)
)
def save_full_frame(self, camera_config: CameraConfig, frame: np.ndarray) -> None:
color_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
@@ -130,11 +128,9 @@ class PendingReviewSegment:
if self._frame is not None:
self.has_frame = True
Path(self.frame_path).parent.mkdir(parents=True, exist_ok=True)
if not cv2.imwrite(
cv2.imwrite(
self.frame_path, self._frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]
):
logger.error("Failed to write review thumbnail to %s", self.frame_path)
)
def get_data(self, ended: bool) -> dict:
end_time = None
@@ -378,16 +374,6 @@ class ReviewSegmentMaintainer(threading.Thread):
"""Forcibly end the pending segment for a camera."""
segment = self.active_review_segments.get(camera)
if segment:
if self.indefinite_events.get(camera):
self.indefinite_events[camera] = {}
now = datetime.datetime.now().timestamp()
if segment.last_alert_time == sys.maxsize:
segment.last_alert_time = now
if segment.last_detection_time == sys.maxsize:
segment.last_detection_time = now
prev_data = segment.get_data(False)
return self._publish_segment_end(segment, prev_data)
return None
-71
View File
@@ -132,77 +132,6 @@ 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
@@ -1,132 +0,0 @@
"""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,123 +91,6 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
return app, mock_publisher
def _create_app_with_dispatcher(self, dispatcher):
"""Create app with a mocked config publisher and a real-ish dispatcher."""
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
dispatcher=dispatcher,
enforce_default_admin=False,
)
async def mock_get_current_user(request: Request):
username = request.headers.get("remote-user")
role = request.headers.get("remote-role")
return {"username": username, "role": role}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app, mock_publisher
@patch("frigate.api.app.find_config_file")
def test_runtime_disabled_camera_survives_unrelated_save(self, mock_find_config):
"""A camera turned off at runtime stays off when another camera is saved."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
dispatcher = MagicMock()
dispatcher.comms = []
# front_door was turned off via the UI: the override is on disk, and
# yaml still says enabled: true. Stand in for the real replay, which
# reads dispatcher.config - the object the endpoint just swapped in.
def fake_reapply():
dispatcher.config.cameras["front_door"].enabled = False
dispatcher.reapply_runtime_state_to_config.side_effect = fake_reapply
try:
app, _ = self._create_app_with_dispatcher(dispatcher)
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {
"cameras": {"back_yard": {"detect": {"fps": 7}}}
},
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()["success"])
# the swap must be repaired: the new config object the API and
# dispatcher now share has to still show front_door as off
dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
self.assertFalse(app.frigate_config.cameras["front_door"].enabled)
self.assertIs(dispatcher.config, app.frigate_config)
# yaml-wins ordering: the surgical clear for rewritten keys
# must run before the replay, or a save that rewrote a toggle
# would have its old override resurrected
call_names = [name for name, _, _ in dispatcher.mock_calls]
self.assertLess(
call_names.index("clear_runtime_state_for_yaml_keys"),
call_names.index("reapply_runtime_state_to_config"),
)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_no_reapply_when_config_is_not_swapped(self, mock_find_config):
"""A restart-required save with no update topic never swaps, so no replay."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
dispatcher = MagicMock()
dispatcher.comms = []
try:
app, _ = self._create_app_with_dispatcher(dispatcher)
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"mqtt": {"host": "other"}},
"requires_restart": 1,
},
)
self.assertEqual(resp.status_code, 200)
dispatcher.reapply_runtime_state_to_config.assert_not_called()
finally:
os.unlink(config_path)
def _write_config_file(self):
"""Write the minimal config to a temp YAML file and return the path."""
yaml = ruamel.yaml.YAML()
+1 -70
View File
@@ -1,10 +1,8 @@
"""Test camera user and password cleanup."""
import multiprocessing as mp
import unittest
from frigate.config import FrigateConfig
from frigate.output.birdseye import BirdsEyeFrameManager, get_canvas_shape
from frigate.output.birdseye import get_canvas_shape
class TestBirdseye(unittest.TestCase):
@@ -47,70 +45,3 @@ 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"]
+3 -1
View File
@@ -8,7 +8,9 @@ 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
@@ -1,55 +0,0 @@
"""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,40 +126,6 @@ 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."""
@@ -246,122 +212,6 @@ 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,34 +491,6 @@ 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()
+3 -147
View File
@@ -21,12 +21,8 @@ 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_fdinfo(
self, drm_devices, read_fdinfo, monotonic, sleep, get_names
):
def test_intel_gpu_stats_fdinfo(self, 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"}
@@ -100,15 +96,13 @@ 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, drm_devices, read_fdinfo, monotonic, sleep, get_names
self, 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"}
@@ -156,145 +150,7 @@ class TestGpuStats(unittest.TestCase):
},
}
@patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._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"}
def test_intel_gpu_stats_no_clients(self, read_fdinfo):
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%",
},
}
+3 -23
View File
@@ -786,15 +786,8 @@ class TestProfileManager(unittest.TestCase):
dispatcher.clear_runtime_state.assert_not_called()
@patch.object(ProfileManager, "_persist_active_profile")
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.
"""
def test_update_config_clears_when_active_profile_reapplies(self, mock_persist):
"""After /api/config/set, an active-profile re-application drops state."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed")
@@ -802,20 +795,7 @@ class TestProfileManager(unittest.TestCase):
new_config = FrigateConfig(**self.config_data)
manager.update_config(new_config)
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")
dispatcher.clear_runtime_state.assert_called_once_with()
@patch.object(ProfileManager, "_persist_active_profile")
def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist):
-130
View File
@@ -1,130 +0,0 @@
"""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
@@ -1,147 +0,0 @@
"""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,25 +131,6 @@ 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()
@@ -1,63 +0,0 @@
"""Tests for embedding cleanup on the main Frigate database.
Embeddings are deleted whether or not semantic search is currently enabled, so
the delete path has to tolerate databases where the vec0 tables were never
created and installs where the sqlite-vec extension is unavailable.
"""
import os
import tempfile
import unittest
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
class TestDeleteEmbeddings(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.db = SqliteVecQueueDatabase(os.path.join(self.tmp_dir.name, "test.db"))
self.db.start()
# the extension is not available to tests, so stand in for a database
# that has it loaded and use a plain table for the deletes
self.db.load_vec_extension = True
def tearDown(self) -> None:
self.db.stop()
self.db.close()
self.tmp_dir.cleanup()
def _flush_writes(self) -> None:
# writes are queued and applied by a worker thread, and the queue is
# FIFO, so awaiting a later write means the earlier ones are done
self.db.execute_sql("PRAGMA user_version = 0").fetchall()
def _create_thumbnails_table(self) -> None:
self.db.execute_sql("CREATE TABLE vec_thumbnails (id TEXT PRIMARY KEY)")
self.db.execute_sql("INSERT INTO vec_thumbnails (id) VALUES ('a'), ('b')")
self._flush_writes()
def _thumbnail_ids(self) -> list[str]:
return [row[0] for row in self.db.execute_sql("SELECT id FROM vec_thumbnails")]
def test_delete_without_tables_does_not_raise(self) -> None:
# semantic search was never enabled, so event cleanup has nothing to do
self.db.delete_embeddings_thumbnail(event_ids=["1700000000.0-abc"])
self.db.delete_embeddings_description(event_ids=["1700000000.0-abc"])
def test_delete_removes_embeddings(self) -> None:
self._create_thumbnails_table()
self.db.delete_embeddings_thumbnail(event_ids=["a"])
self._flush_writes()
self.assertEqual(self._thumbnail_ids(), ["b"])
def test_delete_skipped_without_extension(self) -> None:
self._create_thumbnails_table()
self.db.load_vec_extension = False
self.db.delete_embeddings_thumbnail(event_ids=["a"])
self._flush_writes()
# the vec0 tables cannot be written without the extension
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
-14
View File
@@ -115,13 +115,6 @@ 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):
@@ -141,13 +134,6 @@ 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):
+15 -14
View File
@@ -684,21 +684,22 @@ class TrackedObjectProcessor(threading.Thread):
# check for config updates
updated_topics = self.camera_config_subscriber.check_for_updates()
# 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:
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:
for camera in updated_topics["remove"]:
camera_state = self.camera_states.get(camera)
if camera_state is None:
continue
camera_state.shutdown()
removed_camera_state = self.camera_states[camera]
removed_camera_state.shutdown()
self.camera_states.pop(camera)
self.camera_activity.pop(camera, None)
self.last_motion_detected.pop(camera, None)
+1 -1
View File
@@ -326,7 +326,7 @@ def get_ort_providers(
{
"device_id": device_id,
"trt_fp16_enable": requires_fp16
and os.environ.get("USE_FP16", "True") != "False",
and os.environ.get("USE_FP_16", "True") != "False",
"trt_timing_cache_enable": True,
"trt_engine_cache_enable": True,
"trt_timing_cache_path": os.path.join(

Some files were not shown because too many files have changed in this diff Show More