Compare commits

...
8 Commits
Author SHA1 Message Date
GuoQing LiuandGitHub 4c26cab60e Merge edca412856 into d02a1156b7 2026-07-17 21:32:08 +02:00
d02a1156b7 Miscellaneous fixes (0.18 beta) (#23736)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* Catch faces that become empty after cropping

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

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

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

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

* fix notification test button being blocked by websocket auth

* fix overflowing model names in settings genai widget

* add note about auth debugging

---------

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

* fix: fix button components text

* fix: fix command components scrollbar

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

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

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

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

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

* sync runtime config on camera delete and prune its overrides

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

Extract the config swap that config_set already does into a shared swap_runtime_config helper and call it from both sites, so every collaborator is rebound and the surviving cameras' runtime toggles are re-layered. Also drop the deleted camera's persisted overrides via a new clear_camera so a camera later added under the same name does not inherit them.
2026-07-16 09:05:21 -05:00
Nicolas MowenandGitHub 70d629bf93 Update OpenVINO model generation (#23733)
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
2026-07-16 08:00:52 -06:00
ZhaiSoul edca412856 docs: Add docs Frigate UI mock view 2026-07-12 21:34:08 +08:00
42 changed files with 11409 additions and 329 deletions
+2 -2
View File
@@ -81,10 +81,10 @@ RUN --mount=type=bind,source=docker/main/install_tempio.sh,target=/deps/install_
FROM base_host AS ov-converter
ARG DEBIAN_FRONTEND
# Install OpenVino Runtime and Dev library
# Install OpenVINO for model conversion
COPY docker/main/requirements-ov.txt /requirements-ov.txt
RUN apt-get -qq update \
&& apt-get -qq install -y wget python3 python3-dev python3-distutils gcc pkg-config libhdf5-dev \
&& apt-get -qq install -y wget python3 python3-distutils \
&& wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
&& python3 get-pip.py "pip" \
+39 -8
View File
@@ -1,11 +1,42 @@
import openvino as ov
from openvino.tools import mo
"""Convert the default SSDLite MobileNet v2 model to OpenVINO IR.
ov_model = mo.convert_model(
Replaces the legacy openvino-dev Model Optimizer conversion. The TensorFlow
frontend converts the Object Detection API frozen graph natively; the four TF
outputs are then repacked into the single [1, 1, 100, 7] DetectionOutput-style
tensor that Frigate's OpenVINO detector expects, and the input is flipped to
BGR to match the legacy reverse_input_channels behavior.
"""
import numpy as np
import openvino as ov
from openvino import opset8 as ops
from openvino.preprocess import PrePostProcessor
model = ov.convert_model(
"/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb",
compress_to_fp16=True,
transformations_config="/usr/local/lib/python3.11/dist-packages/openvino/tools/mo/front/tf/ssd_v2_support.json",
tensorflow_object_detection_api_pipeline_config="/models/ssdlite_mobilenet_v2_coco_2018_05_09/pipeline.config",
reverse_input_channels=True,
input=[("image_tensor:0", [1, 300, 300, 3])],
)
ov.save_model(ov_model, "/models/ssdlite_mobilenet_v2.xml")
# rows of (image_id, class_id, score, xmin, ymin, xmax, ymax)
boxes = model.output("detection_boxes:0").get_node().input_value(0)
classes = model.output("detection_classes:0").get_node().input_value(0)
scores = model.output("detection_scores:0").get_node().input_value(0)
# (ymin,xmin,ymax,xmax) -> (xmin,ymin,xmax,ymax)
boxes = ops.gather(boxes, [1, 0, 3, 2], 2)
classes = ops.unsqueeze(classes, 2)
scores = ops.unsqueeze(scores, 2)
image_id = ops.multiply(scores, np.float32(0.0))
detections = ops.concat([image_id, classes, scores, boxes], 2)
detections = ops.unsqueeze(detections, 1)
detections.output(0).get_tensor().set_names({"detection_out"})
model = ov.Model([detections], model.get_parameters(), "ssdlite_mobilenet_v2")
ppp = PrePostProcessor(model)
ppp.input().tensor().set_layout(ov.Layout("NHWC"))
ppp.input().preprocess().reverse_channels()
model = ppp.build()
ov.save_model(model, "/models/ssdlite_mobilenet_v2.xml", compress_to_fp16=True)
+1 -2
View File
@@ -1,3 +1,2 @@
numpy
tensorflow
openvino-dev>=2024.0.0
openvino >= 2026.2.0
+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
+13
View File
@@ -262,6 +262,19 @@ In this example:
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
:::note
If a user isn't getting the role you expect, enable debug logging to see exactly what headers Frigate is receiving from your proxy:
```yaml
logger:
default: info
logs:
frigate.api.auth: debug
```
:::
#### Port Considerations
**Authenticated Port (8971)**
@@ -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">
+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">
+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">
+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()
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>
+2 -13
View File
@@ -31,6 +31,7 @@ from frigate.api.auth import (
get_allowed_cameras_for_filter,
require_role,
)
from frigate.api.config_util import swap_runtime_config
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.request.app_body import (
AppConfigSetBody,
@@ -915,19 +916,7 @@ def config_set(request: Request, body: AppConfigSetBody):
if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config
request.app.frigate_config = config
request.app.genai_manager.update_config(config)
if request.app.profile_manager is not None:
request.app.profile_manager.update_config(config)
if request.app.stats_emitter is not None:
request.app.stats_emitter.config = config
if request.app.dispatcher is not None:
request.app.dispatcher.config = config
for comm in request.app.dispatcher.comms:
comm.config = config
swap_runtime_config(request.app, config)
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
+9 -3
View File
@@ -25,6 +25,7 @@ from frigate.api.auth import (
require_go2rtc_stream_access,
require_role,
)
from frigate.api.config_util import swap_runtime_config
from frigate.api.defs.request.app_body import CameraSetBody
from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig
@@ -1254,9 +1255,14 @@ async def delete_camera(
status_code=500,
)
# Update runtime config
request.app.frigate_config = config
request.app.genai_manager.update_config(config)
# rebind every collaborator to the new config and re-layer runtime
# toggles for the surviving cameras, same as /api/config/set
swap_runtime_config(request.app, config)
# drop the deleted camera's persisted overrides so a camera later
# added under the same name doesn't inherit them
if request.app.dispatcher is not None:
request.app.dispatcher.clear_runtime_state_for_camera(camera_name)
# Publish removal to stop ffmpeg processes and clean up runtime state
request.app.config_publisher.publish_update(
+35
View File
@@ -0,0 +1,35 @@
"""Shared helpers for applying a freshly parsed config to the running app."""
from fastapi import FastAPI
from frigate.config import FrigateConfig
def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
"""Point every long-lived collaborator at a newly parsed config object.
Both /api/config/set and camera deletion re-parse yaml into a fresh
FrigateConfig and must rebind the same set of references, or the API and
the dispatcher drift onto different objects (the API reports one camera
state while the dispatcher acts on another). Runtime toggle overrides are
re-layered last: the swap rebuilt every camera from yaml, so without this a
camera the user turned off would silently come back on.
"""
app.frigate_config = config
app.genai_manager.update_config(config)
if app.profile_manager is not None:
app.profile_manager.update_config(config)
if app.stats_emitter is not None:
app.stats_emitter.config = config
if app.dispatcher is not None:
app.dispatcher.config = config
for comm in app.dispatcher.comms:
comm.config = config
# workers still hold the live toggle values, so correct only the
# config object here rather than re-broadcasting every override
app.dispatcher.reapply_runtime_state_to_config()
+83 -7
View File
@@ -404,38 +404,64 @@ class Dispatcher:
for comm in self.comms:
comm.stop()
def restore_runtime_state(self) -> None:
def apply_runtime_state(self) -> dict[str, dict[str, bool]]:
"""Replay persisted runtime overrides through the camera settings handlers.
Called once after Frigate startup completes so processing threads can
receive the resulting ``config_updater`` broadcasts. Unknown cameras
and topics are skipped; handler exceptions are logged and replay
continues for remaining entries.
Routing through the handlers (rather than mutating config directly) is
deliberate: they publish the ``config_updater`` broadcast and the
retained MQTT state as a side effect, so worker processes and the UI
converge on the replayed value. Unknown cameras and topics are skipped;
handler exceptions are logged and replay continues for the rest.
Returns:
The entries handed to a handler without raising, keyed by camera
then topic. A handler can still refuse the value internally (an ON
payload for a camera that is not enabled_in_config, for example),
so this is not proof the override took effect.
"""
state = self._runtime_state.load()
applied: dict[str, dict[str, bool]] = {}
for camera_name, features in state.items():
if camera_name not in self.config.cameras:
continue
for topic, value in features.items():
handler = self._camera_settings_handlers.get(topic)
if handler is None:
continue
payload = "ON" if value else "OFF"
try:
handler(camera_name, payload)
except Exception:
logger.exception(
"Failed to restore runtime state %s.%s=%s",
"Failed to apply runtime state %s.%s=%s",
camera_name,
topic,
payload,
)
continue
applied.setdefault(camera_name, {})[topic] = value
return applied
def restore_runtime_state(self) -> None:
"""Replay persisted runtime overrides once Frigate startup completes.
Called after every ``config_updater`` subscriber is up so the resulting
broadcasts are not dropped by ZMQ PUB/SUB.
"""
for camera_name, features in self.apply_runtime_state().items():
for topic, value in features.items():
logger.info(
"Restored runtime state: %s.%s=%s",
camera_name,
topic,
payload,
"ON" if value else "OFF",
)
def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
@@ -458,6 +484,56 @@ class Dispatcher:
"""
self._runtime_state.clear_all()
def clear_runtime_state_for_camera(self, camera: str) -> None:
"""Drop all persisted runtime overrides for a deleted camera.
Called by camera deletion so a camera later added under the same name
does not inherit the removed camera's stale toggles.
"""
self._runtime_state.clear_camera(camera)
def reapply_runtime_state_to_config(self) -> None:
"""Re-apply persisted runtime overrides to the swapped-in config object.
After config/set (or a camera delete) parses fresh yaml and swaps the
config, the worker processes still hold the live toggle values and the
overrides are already on disk, so only the in-process config object is
out of date. Unlike apply_runtime_state (used at startup, where workers
must be told), this makes no ZMQ, MQTT, or disk writes, it just corrects
the config the API and dispatcher read.
The field mutations and gates mirror the _on_*_command handlers; keep
the two in sync if a tracked toggle is added or its gate changes.
"""
state = self._runtime_state.load()
for camera_name, features in state.items():
camera = self.config.cameras.get(camera_name)
if camera is None:
continue
for topic, value in features.items():
if topic == "enabled":
if value and not camera.enabled_in_config:
continue
camera.enabled = value
elif topic == "detect":
camera.detect.enabled = value
# detection requires motion, mirror the handler coupling
if value and not camera.motion.enabled:
camera.motion.enabled = True
elif topic == "snapshots":
camera.snapshots.enabled = value
elif topic == "recordings":
if value and not camera.record.enabled_in_config:
continue
camera.record.enabled = value
elif topic == "audio":
if value and not camera.audio.enabled_in_config:
continue
camera.audio.enabled = value
def _on_detect_command(self, camera_name: str, payload: str) -> None:
"""Callback for detect topic."""
detect_settings = self.config.cameras[camera_name].detect
+19
View File
@@ -96,6 +96,25 @@ class RuntimeStatePersistence:
except OSError:
logger.exception("Failed to clear runtime state")
def clear_camera(self, camera: str) -> None:
"""Drop every stored override for a single camera.
Called when a camera is deleted so a camera later added under the same
name does not inherit the removed camera's stale toggles.
"""
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
data = self._read_locked()
cameras = data.get("cameras")
if not isinstance(cameras, dict) or camera not in cameras:
return
del cameras[camera]
self._write_locked(data)
except Timeout:
logger.error("Timed out clearing runtime state for camera")
except OSError:
logger.exception("Failed to clear runtime state for camera")
def clear_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
"""Remove stored entries whose YAML key was just rewritten.
-2
View File
@@ -23,7 +23,6 @@ from frigate.const import (
EXPIRE_AUDIO_ACTIVITY,
INSERT_MANY_RECORDINGS,
INSERT_PREVIEW,
NOTIFICATION_TEST,
REQUEST_REGION_GRID,
UPDATE_AUDIO_ACTIVITY,
UPDATE_AUDIO_TRANSCRIPTION_STATE,
@@ -57,7 +56,6 @@ _WS_BLOCKED_TOPICS = frozenset(
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_BIRDSEYE_LAYOUT,
UPDATE_AUDIO_TRANSCRIPTION_STATE,
NOTIFICATION_TEST,
}
)
+5 -4
View File
@@ -141,6 +141,11 @@ class ProfileManager:
Preserves active profile state: re-snapshots base configs from the new
(freshly parsed) config, then re-applies profile overrides if a profile
was active.
Deliberately does not clear the dispatcher's runtime overrides. This is
the config-save path, not a profile switch: the save only invalidates
the toggles it rewrote in yaml, which /api/config/set already clears by
key. The broad wipe belongs to activate_profile alone.
"""
current_active = self.config.active_profile
self.config = new_config
@@ -164,10 +169,6 @@ class ProfileManager:
self.config.active_profile = None
self._persist_active_profile(None)
# drop all runtime overrides so they don't replay stale values on restart
if self.dispatcher is not None:
self.dispatcher.clear_runtime_state()
def activate_profile(
self,
profile_name: str | None,
@@ -288,6 +288,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
]
if face_frame.size == 0:
logger.debug(f"Empty face crop for {id}")
return
res = self.recognizer.classify(face_frame)
if not res:
+132
View File
@@ -0,0 +1,132 @@
"""Tests for the camera delete endpoint's runtime config handling."""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, Mock, patch
import ruamel.yaml
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestDeleteCameraRuntimeConfig(BaseTestHttp):
"""Deleting a camera must keep the API and dispatcher on the same config."""
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"back_yard": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 720, "width": 1280, "fps": 10},
},
},
}
def _write_config_file(self):
yaml = ruamel.yaml.YAML()
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
yaml.dump(self.minimal_config, f)
f.close()
return f.name
def _create_app_with_dispatcher(self, dispatcher):
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
dispatcher=dispatcher,
enforce_default_admin=False,
)
async def mock_get_current_user(request: Request):
return {
"username": request.headers.get("remote-user"),
"role": request.headers.get("remote-role"),
}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app, mock_publisher
@patch("frigate.api.camera.requests.delete")
@patch("frigate.api.camera.cleanup_camera_files")
@patch("frigate.api.camera.cleanup_camera_db")
@patch("frigate.api.camera.find_config_file")
def test_delete_syncs_dispatcher_and_prunes_runtime_state(
self, mock_find_config, mock_cleanup_db, mock_cleanup_files, mock_go2rtc_delete
):
"""Deleting a camera swaps every config reference and prunes its state."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
mock_cleanup_db.return_value = ({}, [])
dispatcher = MagicMock()
dispatcher.comms = []
try:
app, _ = self._create_app_with_dispatcher(dispatcher)
with AuthTestClient(app) as client:
resp = client.delete("/cameras/front_door")
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()["success"])
# the dispatcher must be moved onto the same new object the API
# now serves, and that object must no longer contain the camera
self.assertIs(dispatcher.config, app.frigate_config)
self.assertNotIn("front_door", dispatcher.config.cameras)
self.assertIn("back_yard", dispatcher.config.cameras)
# surviving cameras' overrides are re-layered onto the new object
dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
# the deleted camera's persisted overrides are pruned
dispatcher.clear_runtime_state_for_camera.assert_called_once_with(
"front_door"
)
finally:
os.unlink(config_path)
if __name__ == "__main__":
unittest.main()
@@ -91,6 +91,123 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
return app, mock_publisher
def _create_app_with_dispatcher(self, dispatcher):
"""Create app with a mocked config publisher and a real-ish dispatcher."""
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
dispatcher=dispatcher,
enforce_default_admin=False,
)
async def mock_get_current_user(request: Request):
username = request.headers.get("remote-user")
role = request.headers.get("remote-role")
return {"username": username, "role": role}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app, mock_publisher
@patch("frigate.api.app.find_config_file")
def test_runtime_disabled_camera_survives_unrelated_save(self, mock_find_config):
"""A camera turned off at runtime stays off when another camera is saved."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
dispatcher = MagicMock()
dispatcher.comms = []
# front_door was turned off via the UI: the override is on disk, and
# yaml still says enabled: true. Stand in for the real replay, which
# reads dispatcher.config - the object the endpoint just swapped in.
def fake_reapply():
dispatcher.config.cameras["front_door"].enabled = False
dispatcher.reapply_runtime_state_to_config.side_effect = fake_reapply
try:
app, _ = self._create_app_with_dispatcher(dispatcher)
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {
"cameras": {"back_yard": {"detect": {"fps": 7}}}
},
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()["success"])
# the swap must be repaired: the new config object the API and
# dispatcher now share has to still show front_door as off
dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
self.assertFalse(app.frigate_config.cameras["front_door"].enabled)
self.assertIs(dispatcher.config, app.frigate_config)
# yaml-wins ordering: the surgical clear for rewritten keys
# must run before the replay, or a save that rewrote a toggle
# would have its old override resurrected
call_names = [name for name, _, _ in dispatcher.mock_calls]
self.assertLess(
call_names.index("clear_runtime_state_for_yaml_keys"),
call_names.index("reapply_runtime_state_to_config"),
)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_no_reapply_when_config_is_not_swapped(self, mock_find_config):
"""A restart-required save with no update topic never swaps, so no replay."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
dispatcher = MagicMock()
dispatcher.comms = []
try:
app, _ = self._create_app_with_dispatcher(dispatcher)
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"mqtt": {"host": "other"}},
"requires_restart": 1,
},
)
self.assertEqual(resp.status_code, 200)
dispatcher.reapply_runtime_state_to_config.assert_not_called()
finally:
os.unlink(config_path)
def _write_config_file(self):
"""Write the minimal config to a temp YAML file and return the path."""
yaml = ruamel.yaml.YAML()
+1 -3
View File
@@ -8,9 +8,7 @@ from frigate.util.builtin import clean_camera_user_pass, escape_special_characte
class TestUserPassCleanup(unittest.TestCase):
def setUp(self) -> None:
self.rtsp_with_pass = "rtsp://user:password@192.168.0.2:554/live"
self.rtsp_with_special_pass = (
"rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\{\}\[\]@@192.168.0.2:554/live"
)
self.rtsp_with_special_pass = "rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\\{\\}\\[\\]@@192.168.0.2:554/live"
self.rtsp_no_pass = "rtsp://192.168.0.3:554/live"
def test_cleanup(self):
+55
View File
@@ -0,0 +1,55 @@
"""Tests for the shared runtime config swap helper."""
import unittest
from unittest.mock import MagicMock
from frigate.api.config_util import swap_runtime_config
class TestSwapRuntimeConfig(unittest.TestCase):
"""swap_runtime_config rebinds every collaborator to the new config."""
def _make_app(self) -> MagicMock:
app = MagicMock()
app.dispatcher.comms = [MagicMock(), MagicMock()]
return app
def test_rebinds_all_references(self) -> None:
app = self._make_app()
config = MagicMock(name="new_config")
swap_runtime_config(app, config)
self.assertIs(app.frigate_config, config)
app.genai_manager.update_config.assert_called_once_with(config)
app.profile_manager.update_config.assert_called_once_with(config)
self.assertIs(app.stats_emitter.config, config)
self.assertIs(app.dispatcher.config, config)
for comm in app.dispatcher.comms:
self.assertIs(comm.config, config)
def test_reapplies_runtime_state_after_swap(self) -> None:
app = self._make_app()
config = MagicMock(name="new_config")
swap_runtime_config(app, config)
# the swap rebuilds cameras from yaml, so overrides must be re-layered
app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
def test_tolerates_missing_optional_collaborators(self) -> None:
app = MagicMock()
app.profile_manager = None
app.stats_emitter = None
app.dispatcher = None
config = MagicMock(name="new_config")
# must not raise when the optional collaborators are absent
swap_runtime_config(app, config)
self.assertIs(app.frigate_config, config)
app.genai_manager.update_config.assert_called_once_with(config)
if __name__ == "__main__":
unittest.main()
@@ -126,6 +126,40 @@ class TestRestoreRuntimeState(unittest.TestCase):
self.dispatcher.restore_runtime_state()
self.handler_mocks["detect"].assert_called_once_with("front_door", "ON")
def test_apply_runtime_state_replays_through_handlers(self) -> None:
"""The extracted method replays every stored entry."""
with patch.object(
self.dispatcher._runtime_state,
"load",
return_value={"front_door": {"enabled": False, "detect": True}},
):
self.dispatcher.apply_runtime_state()
self.handler_mocks["enabled"].assert_called_once_with("front_door", "OFF")
self.handler_mocks["detect"].assert_called_once_with("front_door", "ON")
def test_apply_runtime_state_returns_applied_entries(self) -> None:
"""Callers get back what was replayed, for logging and assertions."""
with patch.object(
self.dispatcher._runtime_state,
"load",
return_value={"front_door": {"enabled": False}, "nope": {"enabled": True}},
):
applied = self.dispatcher.apply_runtime_state()
self.assertEqual(applied, {"front_door": {"enabled": False}})
def test_restore_runtime_state_still_replays(self) -> None:
"""The startup entry point keeps working after the extraction."""
with patch.object(
self.dispatcher._runtime_state,
"load",
return_value={"back_yard": {"snapshots": False}},
):
self.dispatcher.restore_runtime_state()
self.handler_mocks["snapshots"].assert_called_once_with("back_yard", "OFF")
class TestHandlersPersistViaSet(unittest.TestCase):
"""Verify each in-scope handler writes to the runtime state on success."""
@@ -212,6 +246,122 @@ class TestClearPassthrough(unittest.TestCase):
dispatcher.clear_runtime_state()
dispatcher._runtime_state.clear_all.assert_called_once_with()
def test_clear_runtime_state_for_camera_passthrough(self) -> None:
dispatcher = _build_dispatcher({})
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
dispatcher.clear_runtime_state_for_camera("front_door")
dispatcher._runtime_state.clear_camera.assert_called_once_with("front_door")
class TestReapplyRuntimeStateToConfig(unittest.TestCase):
"""The silent re-apply corrects the config object with no side effects."""
def _dispatcher_with(
self, cameras: dict[str, MagicMock], state: dict
) -> Dispatcher:
dispatcher = _build_dispatcher(cameras)
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
dispatcher._runtime_state.load.return_value = state
dispatcher.publish = MagicMock()
return dispatcher
def test_mutates_every_tracked_field(self) -> None:
cameras = {"front_door": _make_camera_mock()}
dispatcher = self._dispatcher_with(
cameras,
{
"front_door": {
"enabled": False,
"detect": False,
"snapshots": False,
"recordings": False,
"audio": False,
}
},
)
dispatcher.reapply_runtime_state_to_config()
cam = cameras["front_door"]
self.assertFalse(cam.enabled)
self.assertFalse(cam.detect.enabled)
self.assertFalse(cam.snapshots.enabled)
self.assertFalse(cam.record.enabled)
self.assertFalse(cam.audio.enabled)
def test_makes_no_zmq_mqtt_or_disk_writes(self) -> None:
dispatcher = self._dispatcher_with(
{"front_door": _make_camera_mock()},
{"front_door": {"enabled": False}},
)
dispatcher.reapply_runtime_state_to_config()
dispatcher.config_updater.publish_update.assert_not_called()
dispatcher._runtime_state.set.assert_not_called()
dispatcher.publish.assert_not_called()
def test_respects_enabled_in_config_gate(self) -> None:
# an ON override for a camera disabled in yaml must not enable it
cameras = {
"front_door": _make_camera_mock(enabled=False, enabled_in_config=False)
}
dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}})
dispatcher.reapply_runtime_state_to_config()
self.assertFalse(cameras["front_door"].enabled)
def test_respects_recordings_and_audio_gates(self) -> None:
# ON overrides for recordings/audio not enabled in yaml must be ignored
cameras = {
"front_door": _make_camera_mock(
record_enabled=False,
record_enabled_in_config=False,
audio_enabled=False,
audio_enabled_in_config=False,
)
}
dispatcher = self._dispatcher_with(
cameras, {"front_door": {"recordings": True, "audio": True}}
)
dispatcher.reapply_runtime_state_to_config()
self.assertFalse(cameras["front_door"].record.enabled)
self.assertFalse(cameras["front_door"].audio.enabled)
def test_applies_on_override_when_gate_passes(self) -> None:
# a camera off in yaml but enabled_in_config keeps its runtime-on state
cameras = {
"front_door": _make_camera_mock(enabled=False, enabled_in_config=True)
}
dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}})
dispatcher.reapply_runtime_state_to_config()
self.assertTrue(cameras["front_door"].enabled)
def test_detect_on_couples_motion(self) -> None:
cam = _make_camera_mock(detect_enabled=False)
cam.motion.enabled = False
dispatcher = self._dispatcher_with(
{"front_door": cam}, {"front_door": {"detect": True}}
)
dispatcher.reapply_runtime_state_to_config()
self.assertTrue(cam.detect.enabled)
self.assertTrue(cam.motion.enabled)
def test_skips_camera_not_in_config(self) -> None:
dispatcher = self._dispatcher_with(
{"front_door": _make_camera_mock()}, {"ghost": {"enabled": False}}
)
# a stale entry for a deleted camera must be ignored, not raise
dispatcher.reapply_runtime_state_to_config()
if __name__ == "__main__":
unittest.main()
+23 -3
View File
@@ -786,8 +786,15 @@ class TestProfileManager(unittest.TestCase):
dispatcher.clear_runtime_state.assert_not_called()
@patch.object(ProfileManager, "_persist_active_profile")
def test_update_config_clears_when_active_profile_reapplies(self, mock_persist):
"""After /api/config/set, an active-profile re-application drops state."""
def test_update_config_preserves_runtime_state_with_active_profile(
self, mock_persist
):
"""A config/set save must not wipe overrides it never rewrote.
The save path clears matching entries itself via
clear_runtime_state_for_yaml_keys; a broad wipe here would drop
overrides for unrelated cameras.
"""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed")
@@ -795,7 +802,20 @@ class TestProfileManager(unittest.TestCase):
new_config = FrigateConfig(**self.config_data)
manager.update_config(new_config)
dispatcher.clear_runtime_state.assert_called_once_with()
dispatcher.clear_runtime_state.assert_not_called()
@patch.object(ProfileManager, "_persist_active_profile")
def test_update_config_still_reapplies_active_profile(self, mock_persist):
"""Dropping the wipe must not disturb profile re-application."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed")
new_config = FrigateConfig(**self.config_data)
manager.update_config(new_config)
self.assertEqual(manager.config, new_config)
self.assertEqual(new_config.active_profile, "armed")
@patch.object(ProfileManager, "_persist_active_profile")
def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist):
+19
View File
@@ -131,6 +131,25 @@ class TestRuntimeStatePersistence(unittest.TestCase):
self.store.clear_all()
self.assertEqual(self.store.load(), {})
def test_clear_camera_removes_only_that_camera(self) -> None:
self.store.set("front_door", "enabled", False)
self.store.set("front_door", "detect", False)
self.store.set("back_yard", "audio", False)
self.store.clear_camera("front_door")
self.assertEqual(self.store.load(), {"back_yard": {"audio": False}})
def test_clear_camera_is_noop_for_unknown_camera(self) -> None:
self.store.set("front_door", "enabled", False)
self.store.clear_camera("side_gate")
self.assertEqual(self.store.load(), {"front_door": {"enabled": False}})
def test_clear_camera_is_safe_when_file_missing(self) -> None:
# No prior set() calls, so the file does not exist
self.store.clear_camera("front_door")
self.assertEqual(self.store.load(), {})
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -115,6 +115,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
)
)
def test_viewer_blocked_from_notification_test(self):
self.assertFalse(
_check_ws_authorization(
"notification_test", "viewer", self.DEFAULT_SEPARATOR
)
)
# --- Admin access ---
def test_admin_can_send_restart(self):
@@ -134,6 +141,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
_check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR)
)
def test_admin_can_send_notification_test(self):
self.assertTrue(
_check_ws_authorization(
"notification_test", "admin", self.DEFAULT_SEPARATOR
)
)
# --- Comma-separated roles ---
def test_comma_separated_admin_viewer_grants_admin(self):
+14 -15
View File
@@ -684,22 +684,21 @@ class TrackedObjectProcessor(threading.Thread):
# check for config updates
updated_topics = self.camera_config_subscriber.check_for_updates()
if "enabled" in updated_topics:
for camera in updated_topics["enabled"]:
if self.camera_states[camera].prev_enabled is None:
self.camera_states[camera].prev_enabled = self.config.cameras[
camera
].enabled
elif "add" in updated_topics:
for camera in updated_topics["add"]:
self.config.cameras[camera] = (
self.camera_config_subscriber.camera_configs[camera]
)
self.create_camera_state(camera)
elif "remove" in updated_topics:
# a single drain can carry several topics at once, so add and
# remove are handled independently rather than as exclusive branches
for camera in updated_topics.get("add", []):
self.config.cameras[camera] = (
self.camera_config_subscriber.camera_configs[camera]
)
self.create_camera_state(camera)
if "remove" in updated_topics:
for camera in updated_topics["remove"]:
removed_camera_state = self.camera_states[camera]
removed_camera_state.shutdown()
camera_state = self.camera_states.get(camera)
if camera_state is None:
continue
camera_state.shutdown()
self.camera_states.pop(camera)
self.camera_activity.pop(camera, None)
self.last_motion_detected.pop(camera, None)
@@ -227,16 +227,18 @@ export function GenAIModelWidget(props: WidgetProps) {
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"justify-between font-normal",
"min-w-0 justify-between font-normal",
!currentLabel && "text-muted-foreground",
fieldClassName,
)}
>
{currentLabel ??
t("configForm.genaiModel.placeholder", {
ns: "views/settings",
defaultValue: "Select or enter a model…",
})}
<span className="truncate">
{currentLabel ??
t("configForm.genaiModel.placeholder", {
ns: "views/settings",
defaultValue: "Select or enter a model…",
})}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -263,12 +265,14 @@ export function GenAIModelWidget(props: WidgetProps) {
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
<Plus className="mr-2 h-4 w-4" />
{t("configForm.genaiModel.useCustom", {
ns: "views/settings",
value: trimmedSearch,
defaultValue: 'Use "{{value}}"',
})}
<Plus className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">
{t("configForm.genaiModel.useCustom", {
ns: "views/settings",
value: trimmedSearch,
defaultValue: 'Use "{{value}}"',
})}
</span>
</CommandItem>
</CommandGroup>
)}
@@ -287,11 +291,11 @@ export function GenAIModelWidget(props: WidgetProps) {
>
<Check
className={cn(
"mr-2 h-4 w-4",
"mr-2 h-4 w-4 shrink-0",
value === model ? "opacity-100" : "opacity-0",
)}
/>
{model}
<span className="truncate">{model}</span>
</CommandItem>
))}
</CommandGroup>
@@ -129,7 +129,7 @@ export function GeneralFilterContent({
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
htmlFor={item}
>
{item.replaceAll("_", " ")}
{t(`logger.logLevel.${item}`, { ns: "views/settings" })}
</Label>
<Switch
key={item}
+3 -1
View File
@@ -3,6 +3,7 @@ import { LogSeverity } from "@/types/log";
import { ReactNode, useMemo } from "react";
import { isIOS } from "react-device-detect";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslation } from "react-i18next";
type ChipProps = {
className?: string;
@@ -50,6 +51,7 @@ type LogChipProps = {
onClickSeverity?: () => void;
};
export function LogChip({ severity, onClickSeverity }: LogChipProps) {
const { t } = useTranslation(["views/settings"]);
const severityClassName = useMemo(() => {
switch (severity) {
case "info":
@@ -73,7 +75,7 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
}
}}
>
{severity}
{t(`logger.logLevel.${severity}`, { ns: "views/settings" })}
</span>
</div>
);
+4 -1
View File
@@ -60,7 +60,10 @@ const CommandList = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
className={cn(
"scrollbar-container max-h-[300px] overflow-y-auto overflow-x-hidden",
className,
)}
{...props}
/>
));
@@ -770,6 +770,34 @@ export default function MotionSearchView({
};
}, [cancelMotionSearchJobViaBeacon]);
const handleBack = useCallback(() => {
if (onBack) {
onBack();
} else {
navigate(-1);
}
}, [navigate, onBack]);
// Dismissing the entry dialog (escape / click outside) before a search has
// run leaves nothing behind it, so cancel the flow instead of revealing an
// empty page.
const handleSearchDialogOpenChange = useCallback(
(nextOpen: boolean) => {
if (
!nextOpen &&
!isSearching &&
!hasSearched &&
searchResults.length === 0
) {
handleBack();
return;
}
setIsSearchDialogOpen(nextOpen);
},
[handleBack, hasSearched, isSearching, searchResults.length],
);
const handleNewSearch = useCallback(() => {
if (jobId && jobCamera) {
void cancelMotionSearchJob(jobId, jobCamera);
@@ -1238,7 +1266,7 @@ export default function MotionSearchView({
<Toaster closeButton={true} position="top-center" />
<MotionSearchDialog
open={isSearchDialogOpen}
onOpenChange={setIsSearchDialogOpen}
onOpenChange={handleSearchDialogOpenChange}
config={config}
cameras={cameras}
selectedCamera={selectedCamera}
@@ -1276,7 +1304,7 @@ export default function MotionSearchView({
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => (onBack ? onBack() : navigate(-1))}
onClick={handleBack}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (