Implement UI for managing multiple models (#24023)

* Implement hardware detection and UI management

* Cleanup Frigate+ detection

* Don't count model as changed

* Fixes for audio map error

* Add descriptions

* Enforce that all model must exist

* Fix hardware picking

* Docs fixes

* WebUI cleanup

* Cleanup handling of scenes

* UI refinement

* Cleanup recommended UI

* test fixews
This commit is contained in:
Nicolas Mowen 2026-08-18 11:37:43 -06:00 committed by Josh Hawkins
parent f5e398036e
commit da135da0fb
59 changed files with 2554 additions and 2587 deletions

View File

@ -4,8 +4,8 @@ edgeTPU:
- key: mobiledet
label: Mobiledet
recommended: true
download: A TensorFlow Lite model is provided in the container at `/edgetpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with `model.path`.
ui: Navigate to **Settings > System > Detectors and model** and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.
download: A TensorFlow Lite model is provided in the container at `/edgetpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with a model's `path`.
ui: Navigate to **Settings > System > Detection models** and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown.
yaml: |-
models:
- devices:
@ -13,9 +13,9 @@ edgeTPU:
- key: yolov9
label: YOLOv9
recommended: false
download: "[Download the model](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite), bind mount the file into the container, and provide the path with `model.path`. Note that the linked model requires a 17-label [labelmap file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) that includes only 17 COCO classes."
download: "[Download the model](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite), bind mount the file into the container, and provide the path with a model's `path`. Note that the linked model requires a 17-label [labelmap file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) that includes only 17 COCO classes."
ui: |-
Navigate to **Settings > System > Detectors and model** and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. Then on the same page, in the **Custom Model** tab, configure the model settings:
Navigate to **Settings > System > Detection models** and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure the model settings:
| Field | Value |
| ---------------------------------------- | ----------------------------------------------------------------- |
@ -44,7 +44,7 @@ hailo8l:
recommended: true
download: If no custom model path or URL is provided, the Hailo detector automatically downloads the default model (YOLOv6n) from the Hailo Model Zoo on first startup based on the detected hardware. Once cached under `/config/model_cache/hailo`, the model works fully offline.
ui: |-
Navigate to **Settings > System > Detectors and model** and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings:
Navigate to **Settings > System > Detection models** and select **Hailo** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure the model settings:
| Field | Value |
| ---------------------------------------- | ----------------------- |
@ -86,7 +86,7 @@ hailo8l:
recommended: false
download: For SSD-based models, provide either a model path or URL to your compiled SSD model. The integration will first check the local path before downloading if necessary. The model file is cached under `/config/model_cache/hailo`.
ui: |-
Navigate to **Settings > System > Detectors and model** and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings:
Navigate to **Settings > System > Detection models** and select **Hailo** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure the model settings:
| Field | Value |
| --------------------------------------- | ------ |
@ -143,7 +143,7 @@ openvino:
EOF
```
ui: |-
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **Intel GPU** (or **Intel NPU**) from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------------- |
@ -171,7 +171,7 @@ openvino:
recommended: false
download: An OpenVINO model is provided in the container at `/openvino-model/ssdlite_mobilenet_v2.xml` and is used by this detector type by default. The model comes from Intel's Open Model Zoo [SSDLite MobileNet V2](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/ssdlite_mobilenet_v2) and is converted to an FP16 precision IR model.
ui: |-
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **Intel GPU** (or **Intel NPU**) from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------ |
@ -206,7 +206,7 @@ openvino:
python3 yolo_to_onnx.py -m yolov7-320
```
ui: |-
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **Intel GPU** (or **Intel NPU**) from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------------- |
@ -243,7 +243,7 @@ openvino:
The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired.
ui: |-
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **Intel GPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------------- |
@ -271,7 +271,7 @@ openvino:
recommended: false
download: YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime).
ui: |-
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **Intel GPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ------------------------------------- | -------------------------------- |
@ -308,7 +308,7 @@ openvino:
EOF
```
ui: |-
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **Intel GPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| --------------------------------------- | --------------------------------- |
@ -402,7 +402,7 @@ openvino:
EOF
```
ui: |-
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `CPU`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **CPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ---------------------------------- |
@ -455,7 +455,7 @@ appleSilicon:
EOF
```
ui: |-
Navigate to **Settings > System > Detectors and model** and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and add a model. The ZMQ endpoint is not reported by the hardware probe, so set `devices` to `zmq:tcp://host.docker.internal:5555` in YAML. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------------- |
@ -491,7 +491,7 @@ appleSilicon:
python3 yolo_to_onnx.py -m yolov7-320
```
ui: |-
Navigate to **Settings > System > Detectors and model** and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and add a model. The ZMQ endpoint is not reported by the hardware probe, so set `devices` to `zmq:tcp://host.docker.internal:5555` in YAML. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------------- |
@ -544,7 +544,7 @@ onnx:
EOF
```
ui: |-
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------------- |
@ -588,7 +588,7 @@ onnx:
EOF
```
ui: |-
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| --------------------------------------- | --------------------------------- |
@ -623,7 +623,7 @@ onnx:
The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired.
ui: |-
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------------- |
@ -651,7 +651,7 @@ onnx:
recommended: false
download: YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime).
ui: |-
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------------- |
@ -747,7 +747,7 @@ onnx:
EOF
```
ui: |-
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------- |
@ -783,7 +783,7 @@ onnx:
python3 yolo_to_onnx.py -m yolov7-320
```
ui: |-
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------------- |
@ -812,9 +812,9 @@ cpu:
- key: ssd
label: MobileNet v2
recommended: true
download: A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with `model.path`.
download: A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with a model's `path`.
ui: |-
Navigate to **Settings > System > Detectors and model** and select **CPU** from the detector type dropdown and click **Add**. Configure the number of threads and click **Add** again to add additional CPU detectors as needed (one per camera is recommended).
Navigate to **Settings > System > Detection models** and select **CPU** from the **Hardware** dropdown and set **Detectors** to the number of detection processes to run (one per camera is recommended).
| Field | Value |
| ----------------- | ----- |
@ -832,7 +832,7 @@ deepstack:
recommended: true
download: This detector runs object detection over the network against a CodeProject.AI or DeepStack server, so no model is downloaded into Frigate itself. Visit the [CodeProject.AI official website](https://www.codeproject.com/Articles/5322557/CodeProject-AI-Server-AI-the-easy-way) to download and install the AI server on your preferred device (e.g. Raspberry Pi, Nvidia Jetson, or other compatible hardware) before configuring the detector.
ui: |-
Navigate to **Settings > System > Detectors and model** and select **DeepStack** from the detector type dropdown and click **Add**. Set the API URL to point to your CodeProject.AI server (e.g., `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection`).
Navigate to **Settings > System > Detection models** and add a model. The CodeProject.AI server is not reported by the hardware probe, so set `devices` to `deepstack:http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection` in YAML.
| Field | Value |
| ------------- | ---------------------------------------------------------------------- |
@ -859,7 +859,7 @@ memryx:
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
ui: |-
Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **MemryX MX3** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------------- |
@ -893,7 +893,7 @@ memryx:
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
ui: |-
Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **MemryX MX3** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------------- |
@ -926,7 +926,7 @@ memryx:
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
ui: |-
Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **MemryX MX3** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ----------------------- |
@ -959,7 +959,7 @@ memryx:
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
ui: |-
Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **MemryX MX3** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ----------------------- |
@ -1005,7 +1005,7 @@ tensorrt:
- USE_FP16=false
```
ui: |-
Navigate to **Settings > System > Detectors and model** and select **TensorRT** from the detector type dropdown and click **Add**, then set the device to `0` (the default GPU index). Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **NVIDIA Jetson** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ------------------------------------------------------------ |
@ -1035,7 +1035,7 @@ synaptics:
recommended: true
download: A synap model is provided in the container at `/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).
ui: |-
Navigate to **Settings > System > Detectors and model** and select **Synaptics** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **Synaptics NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ---------------------------- |
@ -1068,7 +1068,7 @@ rknn:
You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.
ui: |-
Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** select **Rockchip NPU** from the **Hardware** dropdown, then open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | -------------------------------------------------- |
@ -1108,7 +1108,7 @@ rknn:
**Note:** The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html
ui: |-
Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** select **Rockchip NPU** from the **Hardware** dropdown, then open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ----------------------------------------------------------------------- |
@ -1145,7 +1145,7 @@ rknn:
You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.
ui: |-
Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** select **Rockchip NPU** from the **Hardware** dropdown, then open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ---------------------------------------------- |
@ -1182,7 +1182,7 @@ axengine:
recommended: true
download: A yolov9 axmodel is provided in the container at `/axmodels` and is used by this detector type by default. The AXEngine detector downloads its default model from HuggingFace on first startup; once cached, the model works fully offline.
ui: |-
Navigate to **Settings > System > Detectors and model** and select **AXEngine NPU** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
Navigate to **Settings > System > Detection models** and select **AXERA NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ----------------------- |
@ -1195,20 +1195,6 @@ axengine:
| **Model Input D Type** | `int` |
| **Object Detection Model Type** | `yolo-generic` |
yaml: |-
<<<<<<< HEAD
detectors:
axengine:
type: axengine
model:
path: frigate-yolov9-tiny
model_type: yolo-generic
width: 320
height: 320
input_dtype: int
input_pixel_format: bgr
labelmap_path: /labelmap/coco-80.txt
=======
models:
- devices:
- axengine
@ -1219,85 +1205,3 @@ axengine:
input_dtype: int
input_pixel_format: bgr
labelmap_path: /labelmap/coco-80.txt
degirumAiServer:
title: DeGirum AI Server
models:
- key: ai-server-inference
label: AI Server Inference
recommended: true
download: |-
Launch a DeGirum AI server as a Docker container, then point the detector at it. Add this to your `docker-compose.yml`:
```yaml
degirum_detector:
container_name: degirum
image: degirum/aiserver:latest
privileged: true
ports:
- "8778:8778"
```
Set `location` to the server's service name, container name, or `host:port`.
ui: |
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
| Field | Value |
| --- | --- |
| **Location** | `degirum` |
| **Zoo** | `degirum/public` |
| **Token** | your AI Hub token (optional for the public zoo) |
yaml: |
models:
- devices:
- degirum:degirum
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
width: 300
height: 300
input_pixel_format: rgb
degirumLocal:
title: DeGirum Local
models:
- key: local-inference
label: Local Inference
recommended: true
download: Run hardware directly inside the Frigate container with `@local`, removing the AI server hop. The matching device runtime (e.g. the Hailo runtime) must be installed in the container; confirm it with `degirum sys-info`.
ui: |
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
| Field | Value |
| --- | --- |
| **Location** | `@local` |
| **Zoo** | `degirum/public` |
| **Token** | your AI Hub token (optional for the public zoo) |
yaml: |
models:
- devices:
- degirum:@local
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
width: 300
height: 300
input_pixel_format: rgb
degirumCloud:
title: DeGirum AI Hub Cloud
models:
- key: ai-hub-cloud-inference
label: AI Hub Cloud Inference
recommended: true
download: Run inferences on DeGirum's [AI Hub](https://hub.degirum.com) cloud with `@cloud`. Sign up, create an access token, and set it as `token`. Network latency may require lowering your detection fps.
ui: |
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
| Field | Value |
| --- | --- |
| **Location** | `@cloud` |
| **Zoo** | `degirum/public` |
| **Token** | your AI Hub token (optional for the public zoo) |
yaml: |
models:
- devices:
- degirum:@cloud
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
width: 300
height: 300
input_pixel_format: rgb
>>>>>>> 34363affa (Refactor detector and model management)

View File

@ -177,7 +177,7 @@ Custom models may also require different input tensor formats. The colorspace co
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and open the **Custom Model** tab to configure the model path, dimensions, and input format.
Navigate to <NavPath path="Settings > System > Detection models" /> and, on the model you want to change, open the **Custom Model** tab to configure the model path, dimensions, and input format.
| Field | Description |
| --------------------------------------------- | ------------------------------------ |

View File

@ -154,7 +154,7 @@ Here are some common starter configuration examples. These can be configured thr
1. Navigate to <NavPath path="Settings > System > MQTT" /> and configure the MQTT connection to your Home Assistant Mosquitto broker
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)`
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
3. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
@ -232,7 +232,7 @@ cameras:
1. Navigate to <NavPath path="Settings > System > MQTT" /> and set **Enable MQTT** to off
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
3. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
@ -308,8 +308,8 @@ cameras:
1. Navigate to <NavPath path="Settings > System > MQTT" /> and configure the connection to your MQTT broker
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `openvino` and **Device** `AUTO`
4. On the same page, in the **Custom Model** tab, configure the OpenVINO model path and settings
3. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Intel GPU** from the **Hardware** dropdown
4. On the same model, open the **Custom Model** tab and configure the OpenVINO model path and settings
5. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
6. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
7. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL

View File

@ -165,7 +165,7 @@ 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`.
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown.
</TabItem>
<TabItem value="yaml">
@ -184,7 +184,7 @@ models:
<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.
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown and check each Coral the model should run on.
</TabItem>
<TabItem value="yaml">
@ -206,7 +206,7 @@ _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.
Navigate to <NavPath path="Settings > System > Detection models" /> and select the **Coral EdgeTPU** entry from the **Hardware** dropdown.
</TabItem>
<TabItem value="yaml">
@ -225,7 +225,7 @@ models:
<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`.
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (PCIe)** from the **Hardware** dropdown.
</TabItem>
<TabItem value="yaml">
@ -244,7 +244,7 @@ models:
<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.
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (PCIe)** from the **Hardware** dropdown and check each Coral the model should run on.
</TabItem>
<TabItem value="yaml">
@ -264,7 +264,7 @@ models:
<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`).
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown. USB and PCIe Corals are listed as separate hardware, so mixing the two on one model has to be done in YAML.
</TabItem>
<TabItem value="yaml">
@ -354,7 +354,7 @@ Intel NPUs cannot be used under Home Assistant OS, which does not include the NP
:::warning
The network-based detectors (Deepstack, DeGirum, and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, DeGirum ignores `zoo` and `token`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
The network-based detectors (Deepstack and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
:::
@ -536,7 +536,7 @@ When using CPU detectors, you can add one CPU detector per camera. Adding more d
:::warning
The network-based detectors (Deepstack, DeGirum, and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, DeGirum ignores `zoo` and `token`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
The network-based detectors (Deepstack and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
:::
@ -809,101 +809,6 @@ Explanation of the parameters:
- **example**: Specifying `output_name = "frigate-{quant}-{input_basename}-{soc}-v{tk_version}"` could result in a model called `frigate-i8-my_model-rk3588-v2.3.0.rknn`.
- `config`: Configuration passed to `rknn-toolkit2` for model conversion. For an explanation of all available parameters have a look at section "2.2. Model configuration" of [this manual](https://github.com/MarcA711/rknn-toolkit2/releases/download/v2.3.2/03_Rockchip_RKNPU_API_Reference_RKNN_Toolkit2_V2.3.2_EN.pdf).
<<<<<<< HEAD
=======
## DeGirum
:::warning
The network-based detectors (Deepstack, DeGirum, and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, DeGirum ignores `zoo` and `token`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
:::
DeGirum is a detector that can use any type of hardware listed on [their website](https://hub.degirum.com). DeGirum can be used with local hardware through a DeGirum AI Server, or through the use of `@local`. You can also connect directly to DeGirum's AI Hub to run inferences. **Please Note:** This detector _cannot_ be used for commercial purposes.
### Configuration {#configuration-degirum}
#### AI Server Inference
Before starting with the config file for this section, you must first launch an AI server. DeGirum has an AI server ready to use as a docker container. Add this to your `docker-compose.yml` to get started:
```yaml
degirum_detector:
container_name: degirum
image: degirum/aiserver:latest
privileged: true
ports:
- "8778:8778"
```
All supported hardware will automatically be found on your AI server host as long as relevant runtimes and drivers are properly installed on your machine. Refer to [DeGirum's docs site](https://docs.degirum.com/pysdk/runtimes-and-drivers) if you have any trouble.
Once completed, configure the detector as follows:
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumAiServer.models} />
The model is set on the same `models` entry as the DeGirum device. You can set it to:
- A model listed on the [AI Hub](https://hub.degirum.com)
- If this is what you choose to do, the correct model will be downloaded onto your machine before running.
- A local directory acting as a zoo. See DeGirum's docs site [for more information](https://docs.degirum.com/pysdk/user-guide-pysdk/organizing-models#model-zoo-directory-structure).
- A path to some model.json.
```yaml
models:
- devices:
- degirum:<location>
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1 # directory to model .json and file
width: 300 # width is in the model name as the first number in the "int"x"int" section
height: 300 # height is in the model name as the second number in the "int"x"int" section
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
```
#### Local Inference
It is also possible to eliminate the need for an AI server and run the hardware directly. The benefit of this approach is that you eliminate any bottlenecks that occur when transferring prediction results from the AI server docker container to the frigate one. However, the method of implementing local inference is different for every device and hardware combination, so it's usually more trouble than it's worth. A general guideline to achieve this would be:
1. Ensuring that the frigate docker container has the runtime you want to use. So for instance, running `@local` for Hailo means making sure the container you're using has the Hailo runtime installed.
2. To double check the runtime is detected by the DeGirum detector, make sure the `degirum sys-info` command properly shows whatever runtimes you mean to install.
3. Create a DeGirum detector in your configuration.
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumLocal.models} />
Once the DeGirum device is set up, you can choose a model on the same `models` entry in the `config.yml` file.
```yaml
models:
- devices:
- degirum:<location>
path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
width: 300 # width is in the model name as the first number in the "int"x"int" section
height: 300 # height is in the model name as the second number in the "int"x"int" section
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
```
#### AI Hub Cloud Inference
If you do not possess whatever hardware you want to run, there's also the option to run cloud inferences. Do note that your detection fps might need to be lowered as network latency does significantly slow down this method of detection. For use with Frigate, we highly recommend using a local AI server as described above. To set up cloud inferences,
1. Sign up at [DeGirum's AI Hub](https://hub.degirum.com).
2. Get an access token.
3. Create a DeGirum detector in your configuration.
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumCloud.models} />
Once the DeGirum device is set up, you can choose a model on the same `models` entry in the `config.yml` file.
```yaml
models:
- devices:
- degirum:<location>
path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
width: 300 # width is in the model name as the first number in the "int"x"int" section
height: 300 # height is in the model name as the second number in the "int"x"int" section
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
```
>>>>>>> 34363affa (Refactor detector and model management)
## AXERA
Hardware accelerated object detection is supported on the following SoCs:

View File

@ -204,8 +204,8 @@ You need to refer to **Configure hardware acceleration** above to enable the con
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `OpenVINO` and **Device** `GPU`
2. On the same page, in the **Custom Model** tab, configure the model settings for OpenVINO:
1. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Intel GPU** from the **Hardware** dropdown
2. On the same model, open the **Custom Model** tab and configure the model settings for OpenVINO:
| Field | Value |
| ---------------------------------------- | ------------------------------------------ |
@ -270,7 +270,7 @@ services:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`.
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown.
</TabItem>
<TabItem value="yaml">

View File

@ -59,7 +59,7 @@ You can view all of your submitted images at [https://plus.frigate.video](https:
Once you have [requested your first model](../plus/first_model.md) and gotten your own model ID, it can be used with a special model path. No other information needs to be configured for Frigate+ models because it fetches the remaining config from Frigate+ automatically.
You can either choose the new model from the <NavPath path="Settings > System > Detectors and model" /> pane in the Frigate UI (the **Frigate+ Model** tab), or manually set the model at the root level in your config:
You can either choose the new model from the <NavPath path="Settings > System > Detection models" /> pane in the Frigate UI (on the **Frigate+** tab of the model you want to change), or set it on that model in your config:
```yaml
models:

View File

@ -30,7 +30,7 @@ Models available in Frigate+ can be used with a special model path. No other inf
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detectors and model" />. In the **Detection Model** section, choose the **Frigate+** tab. Select your new Frigate+ model from the **Available Frigate+ models** dropdown, then click **Save**. Restart Frigate to apply the change.
Navigate to <NavPath path="Settings > System > Detection models" />. On the model you want to change, choose the **Frigate+** tab and select your new Frigate+ model from the **Available Frigate+ models** dropdown, then click **Save**. Restart Frigate to apply the change.
</TabItem>
<TabItem value="yaml">

View File

@ -133,7 +133,7 @@ The process was killed by the CPU for executing an unsupported instruction. Ther
<FaqItem id="onnx-invalidprotobuf" question="ONNX Runtime InvalidProtobuf / failed to load model">
ONNX Runtime could not parse the model file. The file exists but its contents are not a valid ONNX model, usually a corrupted or interrupted download in `model_cache`, or the wrong file pointed at by `model.path`. Delete the cached model file so Frigate re-downloads it, and confirm `model.path` points at an actual `.onnx` model. See [ONNX detector configuration](/configuration/object_detectors#onnx).
ONNX Runtime could not parse the model file. The file exists but its contents are not a valid ONNX model, usually a corrupted or interrupted download in `model_cache`, or the wrong file pointed at by a model's `path`. Delete the cached model file so Frigate re-downloads it, and confirm the model's `path` points at an actual `.onnx` model. See [ONNX detector configuration](/configuration/object_detectors#onnx).
</FaqItem>

View File

@ -63,8 +63,7 @@ SYSTEM_NAV: dict[str, tuple[str, str]] = {
"environment_vars": ("System", "Environment variables"),
"telemetry": ("System", "Telemetry"),
"birdseye": ("System", "Birdseye"),
"detectors": ("System", "Detectors and model"),
"model": ("System", "Detectors and model"),
"models": ("System", "Detection models"),
}
# All known top-level config section keys

View File

@ -4010,6 +4010,49 @@ paths:
security:
- frigateAdminAuth: []
x-required-role: admin
/hardware/probe:
get:
tags:
- Hardware
summary: Probe Hardware
description: |-
**Access:** Admin role required.
Get the object detection hardware attached to this system.
Args:
refresh: Probe again instead of returning the cached result
Returns:
Every kind of detection hardware that was found
operationId: probe_hardware_hardware_probe_get
parameters:
- name: refresh
in: query
required: false
schema:
type: boolean
default: false
title: Refresh
responses:
'200':
description: Successful Response
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DetectionHardware'
title: Response Probe Hardware Hardware Probe Get
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/events:
get:
tags:
@ -7842,6 +7885,46 @@ components:
required:
- ids
title: DeleteFaceImagesBody
DetectionHardware:
properties:
key:
type: string
title: Hardware key
description: Stable identifier for this kind of hardware.
detector:
type: string
title: Detector type
description: The detector that drives this hardware.
name:
type: string
title: Hardware name
description: Human readable name for this kind of hardware.
units:
items:
$ref: '#/components/schemas/HardwareUnit'
type: array
title: Units
description: Each physical piece of this hardware that was found.
count:
type: integer
title: Unit count
description: How many units were found.
unlimited:
type: boolean
title: Unlimited detectors
description: Whether this hardware can run more inference processes
than there are units.
type: object
required:
- key
- detector
- name
- units
- count
- unlimited
title: DetectionHardware
description: A kind of detection hardware, and every unit of it that was
found.
EventCreateResponse:
properties:
success:
@ -8569,6 +8652,24 @@ components:
title: Detail
type: object
title: HTTPValidationError
HardwareUnit:
properties:
device:
type: string
title: Device string
description: The value to put in a model's devices list, for example
'edgetpu:pci:1'.
label:
type: string
title: Unit label
description: How to identify this unit among others of the same kind,
for example 'PCIe 1'.
type: object
required:
- device
- label
title: HardwareUnit
description: One physical piece of hardware.
Last24HoursReview:
properties:
reviewed_alert:

View File

@ -1330,8 +1330,18 @@ def categorized_object_names(
@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())])
def get_audio_labels():
def get_audio_labels(request: Request):
labels = load_labels("/audio-labelmap.txt", prefill=521)
# configured overrides group several audio classes under one label, and the
# detector merges them over the defaults at runtime. Offer them here too, or
# a grouped label could never be picked in the UI.
config: FrigateConfig = request.app.frigate_config
labels.update(config.audio.labelmap)
for camera in config.cameras.values():
labels.update(camera.audio.labelmap)
return JSONResponse(content=labels)

View File

@ -8,6 +8,7 @@ class Tags(Enum):
chat = "Chat"
events = "Events"
export = "Export"
hardware = "Hardware"
classification = "Classification"
logs = "Logs"
media = "Media"

View File

@ -21,6 +21,7 @@ from frigate.api import (
debug_replay,
event,
export,
hardware,
media,
motion_search,
notification,
@ -145,6 +146,7 @@ def create_fastapi_app(
app.include_router(preview.router)
app.include_router(notification.router)
app.include_router(export.router)
app.include_router(hardware.router)
app.include_router(event.router)
app.include_router(media.router)
app.include_router(motion_search.router)

30
frigate/api/hardware.py Normal file
View File

@ -0,0 +1,30 @@
"""Hardware discovery APIs."""
import logging
from fastapi import APIRouter, Depends
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
from frigate.detectors.hardware import DetectionHardware, hardware_prober
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.hardware])
@router.get(
"/hardware/probe",
response_model=list[DetectionHardware],
dependencies=[Depends(require_role(["admin"]))],
)
def probe_hardware(refresh: bool = False) -> list[DetectionHardware]:
"""Get the object detection hardware attached to this system.
Args:
refresh: Probe again instead of returning the cached result
Returns:
Every kind of detection hardware that was found
"""
return hardware_prober.probe(refresh=refresh)

View File

@ -62,10 +62,10 @@ class DetectConfig(FrigateBaseModel):
title="Detect width",
description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
)
scene: SceneEnum | None = Field(
default=None,
scene: SceneEnum = Field(
default=SceneEnum.all,
title="Detect scene",
description="The environment this camera looks at, used to pick which of the configured models runs on it. Defaults to the model with a scene of 'all'.",
description="The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'.",
)
fps: int = Field(
default=5,

View File

@ -807,37 +807,37 @@ class FrigateConfig(FrigateBaseModel):
}
self._all_labels = labels
def _resolve_camera_model(self, name: str, scene: SceneEnum | None) -> ModelConfig:
def _resolve_camera_model(self, name: str, scene: SceneEnum) -> ModelConfig:
"""Resolve which model a camera runs on.
A camera may name a scene no model is configured for, which is valid as
long as an 'all' model is there to fall back to.
Args:
name: Name of the camera
scene: The camera's configured detect scene, if any
scene: The camera's detect scene, which defaults to 'all'
Returns:
The model the camera runs on
"""
by_scene = {model.scene: model for model in self.models}
model = by_scene.get(scene)
if scene is not None:
model = by_scene.get(scene)
if model is None:
raise ValueError(
f"Camera '{name}' has a detect scene of '{scene.value}', but no model is configured for that scene."
)
if model is not None:
return model
default = by_scene.get(SceneEnum.all) or (
self.models[0] if len(self.models) == 1 else None
)
default = by_scene.get(SceneEnum.all)
if default is None:
raise ValueError(
f"Camera '{name}' must set detect -> scene, because more than one model is configured and none of them uses a scene of 'all'."
f"Camera '{name}' has a detect scene of '{scene.value}', but no model is configured for that scene or for 'all'."
)
logger.warning(
"Camera '%s' has a detect scene of '%s', but no model is configured for that scene, so the 'all' model is used",
name,
scene.value,
)
return default
@model_validator(mode="after")

View File

@ -0,0 +1,368 @@
"""Discovery of object detection hardware attached to the system.
Every probe here is a filesystem read. Nothing shells out, initializes a
runtime, or opens a device, so this is cheap enough to run from the API process
while detector children hold the hardware.
Hardware is reported whether or not this image ships a detector that can drive
it. Matching hardware to an image is a separate concern.
"""
import logging
import os
from glob import glob
from pydantic import BaseModel, Field
from frigate.const import SUPPORTED_RK_SOCS
from frigate.detectors.detector_types import config_types
from frigate.util.services import enumerate_drm_devices
logger = logging.getLogger(__name__)
# roots the probes read from, so tests can point them at a fixture tree
SYS_ROOT = "/sys"
DEV_ROOT = "/dev"
PROC_ROOT = "/proc"
ETC_ROOT = "/etc"
# a Coral reports as Global Unichip until its firmware is loaded, then as Google
CORAL_USB_IDS = {("1a6e", "089a"), ("18d1", "9302")}
INTEL_DRM_DRIVERS = ("i915", "xe")
AMD_DRM_DRIVERS = ("amdgpu",)
class HardwareUnit(BaseModel):
"""One physical piece of hardware."""
device: str = Field(
title="Device string",
description="The value to put in a model's devices list, for example 'edgetpu:pci:1'.",
)
label: str = Field(
title="Unit label",
description="How to identify this unit among others of the same kind, for example 'PCIe 1'.",
)
class DetectionHardware(BaseModel):
"""A kind of detection hardware, and every unit of it that was found."""
key: str = Field(
title="Hardware key",
description="Stable identifier for this kind of hardware.",
)
detector: str = Field(
title="Detector type",
description="The detector that drives this hardware.",
)
name: str = Field(
title="Hardware name",
description="Human readable name for this kind of hardware.",
)
units: list[HardwareUnit] = Field(
title="Units",
description="Each physical piece of this hardware that was found.",
)
count: int = Field(
title="Unit count",
description="How many units were found.",
)
unlimited: bool = Field(
title="Unlimited detectors",
description="Whether this hardware can run more inference processes than there are units.",
)
def _read(path: str) -> str | None:
"""Read a small file, returning None if it cannot be read."""
try:
with open(path) as f:
return f.read().strip()
except OSError:
return None
def _is_shareable(detector: str) -> bool:
"""Whether a detector lets the same device run more than one process."""
config_class = config_types.get(detector)
# a detector missing from this image is assumed to behave like most of them
return config_class.shareable if config_class else True
def _hardware(
key: str, detector: str, name: str, units: list[HardwareUnit]
) -> DetectionHardware:
return DetectionHardware(
key=key,
detector=detector,
name=name,
units=units,
count=len(units),
unlimited=_is_shareable(detector),
)
def detect_coral_pci() -> DetectionHardware | None:
"""Find PCIe and M.2 Coral accelerators, which register as apex devices."""
names = sorted(
os.path.basename(path) for path in glob(f"{SYS_ROOT}/class/apex/apex_*")
)
if not names:
return None
units = [
HardwareUnit(device=f"edgetpu:pci:{index}", label=f"PCIe {index}")
for index in range(len(names))
]
return _hardware("edgetpu:pci", "edgetpu", "Coral EdgeTPU (PCIe)", units)
def detect_coral_usb() -> DetectionHardware | None:
"""Find USB Coral accelerators by their USB vendor and product ids."""
found = 0
for device_dir in sorted(glob(f"{SYS_ROOT}/bus/usb/devices/*")):
vendor = _read(os.path.join(device_dir, "idVendor"))
product = _read(os.path.join(device_dir, "idProduct"))
if vendor and product and (vendor.lower(), product.lower()) in CORAL_USB_IDS:
found += 1
if not found:
return None
units = [
HardwareUnit(device=f"edgetpu:usb:{index}", label=f"USB {index}")
for index in range(found)
]
return _hardware("edgetpu:usb", "edgetpu", "Coral EdgeTPU (USB)", units)
def _drm_devices(drivers: tuple[str, ...]) -> list[str]:
"""PCI addresses of DRM devices bound to one of the given drivers."""
return sorted(
pdev for pdev, driver in enumerate_drm_devices().items() if driver in drivers
)
def detect_intel_gpu() -> DetectionHardware | None:
"""Find Intel GPUs through their DRM driver."""
pdevs = _drm_devices(INTEL_DRM_DRIVERS)
if not pdevs:
return None
# OpenVINO reports a lone GPU as "GPU" and enumerates them as GPU.0, GPU.1
# only when there is more than one
if len(pdevs) == 1:
units = [HardwareUnit(device="openvino:GPU", label=pdevs[0])]
else:
units = [
HardwareUnit(device=f"openvino:GPU.{index}", label=pdev)
for index, pdev in enumerate(pdevs)
]
return _hardware("openvino:GPU", "openvino", "Intel GPU", units)
def detect_intel_npu() -> DetectionHardware | None:
"""Find Intel NPUs, which register as accel devices bound to intel_vpu."""
units = []
for accel_path in sorted(glob(f"{SYS_ROOT}/class/accel/accel*")):
try:
driver = os.path.basename(os.readlink(f"{accel_path}/device/driver"))
except OSError:
continue
if driver != "intel_vpu":
continue
units.append(
HardwareUnit(device="openvino:NPU", label=os.path.basename(accel_path))
)
if not units:
return None
# OpenVINO has no way to address a specific NPU, so only the first is usable
return _hardware("openvino:NPU", "openvino", "Intel NPU", units[:1])
def detect_amd_gpu() -> DetectionHardware | None:
"""Find AMD GPUs through their DRM driver."""
pdevs = _drm_devices(AMD_DRM_DRIVERS)
if not pdevs:
return None
# ROCm runs through onnx, whose MIGraphX provider takes no device index, so
# only one is addressable
units = [HardwareUnit(device="onnx", label=pdevs[0])]
return _hardware("onnx:amd", "onnx", "AMD GPU", units)
def detect_nvidia_gpu() -> DetectionHardware | None:
"""Find discrete Nvidia GPUs through the nvidia driver's proc entries."""
units = []
for index, gpu_dir in enumerate(sorted(glob(f"{PROC_ROOT}/driver/nvidia/gpus/*"))):
information = _read(os.path.join(gpu_dir, "information")) or ""
name = f"GPU {index}"
for line in information.splitlines():
if line.startswith("Model:"):
name = line.split(":", 1)[1].strip()
break
units.append(HardwareUnit(device=f"onnx:{index}", label=name))
if not units:
return None
# the model name is more useful as the hardware name when there is only one
name = units[0].label if len(units) == 1 else "NVIDIA GPU"
return _hardware("onnx:nvidia", "onnx", name, units)
def detect_jetson() -> DetectionHardware | None:
"""Find an Nvidia Jetson, whose integrated GPU runs through tensorrt."""
is_jetson = os.path.isfile(f"{ETC_ROOT}/nv_tegra_release") or os.path.exists(
f"{SYS_ROOT}/devices/gpu.0/load"
)
if not is_jetson:
return None
units = [HardwareUnit(device="tensorrt:0", label="Integrated GPU")]
return _hardware("tensorrt", "tensorrt", "NVIDIA Jetson", units)
def _dev_units(pattern: str, device: str, label: str) -> list[HardwareUnit]:
"""Build units from device nodes matching a glob."""
return [
HardwareUnit(device=device.format(index=index), label=f"{label} {index}")
for index in range(len(glob(f"{DEV_ROOT}/{pattern}")))
]
def detect_hailo() -> DetectionHardware | None:
"""Find Hailo accelerators by their device nodes."""
nodes = sorted(glob(f"{DEV_ROOT}/hailo*"))
if not nodes:
return None
# the hailo runtime schedules across every attached device itself, so there
# is nothing to address individually
units = [HardwareUnit(device="hailo8l:PCIe", label=os.path.basename(nodes[0]))]
return _hardware("hailo8l", "hailo8l", "Hailo", units)
def detect_memryx() -> DetectionHardware | None:
"""Find MemryX accelerators by their device nodes."""
units = _dev_units("memx*", "memryx:PCIe:{index}", "PCIe")
if not units:
return None
return _hardware("memryx", "memryx", "MemryX MX3", units)
def detect_rockchip() -> DetectionHardware | None:
"""Find a Rockchip NPU by reading the SoC from the device tree."""
compatible = _read(f"{PROC_ROOT}/device-tree/compatible")
if not compatible:
return None
soc = compatible.split(",")[-1].strip("\x00")
if soc not in SUPPORTED_RK_SOCS:
return None
units = [HardwareUnit(device="rknn", label=soc.upper())]
return _hardware("rknn", "rknn", f"Rockchip NPU ({soc.upper()})", units)
def detect_axengine() -> DetectionHardware | None:
"""Find an AXERA accelerator by its control device node."""
if not os.path.exists(f"{DEV_ROOT}/axcl_host"):
return None
units = [HardwareUnit(device="axengine", label="AXERA")]
return _hardware("axengine", "axengine", "AXERA NPU", units)
def detect_synaptics() -> DetectionHardware | None:
"""Find a Synaptics NPU by its device node."""
if not os.path.exists(f"{DEV_ROOT}/synap"):
return None
units = [HardwareUnit(device="synaptics", label="Synaptics")]
return _hardware("synaptics", "synaptics", "Synaptics NPU", units)
def detect_cpu() -> DetectionHardware:
"""The CPU, which is always available."""
units = [HardwareUnit(device="cpu", label="CPU")]
return _hardware("cpu", "cpu", "CPU", units)
# ordered so accelerators are offered ahead of the CPU fallback
PROBES = (
detect_coral_pci,
detect_coral_usb,
detect_hailo,
detect_memryx,
detect_intel_npu,
detect_intel_gpu,
detect_nvidia_gpu,
detect_jetson,
detect_amd_gpu,
detect_rockchip,
detect_axengine,
detect_synaptics,
detect_cpu,
)
class HardwareProber:
"""Probes for detection hardware, caching the result for the process."""
_hardware: list[DetectionHardware] | None = None
def probe(self, refresh: bool = False) -> list[DetectionHardware]:
"""Get the detection hardware attached to this system.
Args:
refresh: Probe again instead of using the cached result
Returns:
Every kind of detection hardware that was found
"""
if self._hardware is not None and not refresh:
return self._hardware
found = []
for probe in PROBES:
try:
hardware = probe()
except Exception:
logger.warning("Failed to probe for %s", probe.__name__, exc_info=True)
continue
if hardware is not None:
found.append(hardware)
logger.debug("Detected hardware: %s", [h.key for h in found])
self._hardware = found
return found
hardware_prober = HardwareProber()

View File

@ -0,0 +1,55 @@
"""Tests for the audio labels API."""
import unittest
from unittest.mock import patch
from frigate.models import Event
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestHttpAudioLabels(BaseTestHttp):
def setUp(self):
super().setUp([Event])
def _labels(self, config: dict | None = None) -> dict[str, str]:
if config:
self.minimal_config.update(config)
app = self.create_app()
with patch(
"frigate.api.app.load_labels", return_value={0: "speech", 1: "bark"}
):
with AuthTestClient(app) as client:
response = client.get("/audio_labels")
self.assertEqual(response.status_code, 200)
return response.json()
def test_the_default_labels_are_returned(self):
self.assertEqual(self._labels(), {"0": "speech", "1": "bark"})
def test_a_global_labelmap_override_is_offered(self):
# grouping several classes under one label makes that label selectable
labels = self._labels({"audio": {"labelmap": {0: "noise", 1: "noise"}}})
self.assertEqual(set(labels.values()), {"noise"})
def test_a_camera_labelmap_override_is_offered(self):
labels = self._labels(
{
"cameras": {
"front_door": {
**self.minimal_config["cameras"]["front_door"],
"audio": {"labelmap": {1: "dogs"}},
}
}
}
)
self.assertEqual(labels["1"], "dogs")
self.assertEqual(labels["0"], "speech")
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -159,7 +159,7 @@ class TestConfig(unittest.TestCase):
FrigateConfig(**(deep_merge(config, self.minimal)))
@patch("frigate.detectors.detector_config.load_labels")
def test_camera_scene_must_match_a_model(self, mock_labels):
def test_camera_scene_without_a_model_falls_back_to_all(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [{"devices": ["cpu"]}],
@ -175,6 +175,27 @@ class TestConfig(unittest.TestCase):
},
}
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert frigate_config.model_for_camera("back").scene == SceneEnum.all
@patch("frigate.detectors.detector_config.load_labels")
def test_camera_scene_without_a_model_or_a_default(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [{"scene": "indoor", "devices": ["cpu"]}],
"cameras": {
"back": {
"detect": {"scene": "outdoor"},
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]},
]
},
},
},
}
with self.assertRaises(ValidationError):
FrigateConfig(**(deep_merge(config, self.minimal)))
@ -1298,7 +1319,7 @@ class TestConfig(unittest.TestCase):
70: "dogs",
75: "dogs",
}
assert frigate_config.model.merged_labelmap[69] != "dogs"
assert frigate_config.primary_model.merged_labelmap[69] != "dogs"
def test_default_labelmap_empty(self):
config = {

View File

@ -0,0 +1,247 @@
"""Tests for detection hardware discovery."""
import os
import tempfile
import unittest
from unittest.mock import patch
from frigate.detectors import hardware
from frigate.detectors.detector_types import config_types
from frigate.detectors.hardware import HardwareProber
def write(path: str, content: str = "") -> None:
"""Create a file and any parent directories."""
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(content)
class HardwareProbeTestCase(unittest.TestCase):
"""Points every probe at an empty fixture tree, so nothing is found by default."""
def setUp(self):
self.root = tempfile.TemporaryDirectory()
self.addCleanup(self.root.cleanup)
for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT"):
sub = os.path.join(self.root.name, name.split("_")[0].lower())
os.makedirs(sub, exist_ok=True)
patcher = patch.object(hardware, name, sub)
patcher.start()
self.addCleanup(patcher.stop)
setattr(self, name.lower(), sub)
drm = patch.object(hardware, "enumerate_drm_devices", return_value={})
self.drm = drm.start()
self.addCleanup(drm.stop)
def probe(self) -> dict[str, hardware.DetectionHardware]:
return {found.key: found for found in HardwareProber().probe()}
class TestNoHardware(HardwareProbeTestCase):
def test_only_the_cpu_is_reported(self):
self.assertEqual(list(self.probe()), ["cpu"])
def test_the_cpu_is_unlimited(self):
self.assertTrue(self.probe()["cpu"].unlimited)
class TestCoral(HardwareProbeTestCase):
def test_each_apex_device_is_a_unit(self):
for name in ("apex_0", "apex_1"):
os.makedirs(os.path.join(self.sys_root, "class", "apex", name))
coral = self.probe()["edgetpu:pci"]
self.assertEqual(coral.count, 2)
self.assertEqual(
[unit.device for unit in coral.units],
["edgetpu:pci:0", "edgetpu:pci:1"],
)
def test_a_coral_is_not_unlimited(self):
os.makedirs(os.path.join(self.sys_root, "class", "apex", "apex_0"))
self.assertFalse(self.probe()["edgetpu:pci"].unlimited)
def test_usb_corals_are_found_by_their_usb_ids(self):
usb = os.path.join(self.sys_root, "bus", "usb", "devices")
# a Coral reports as Global Unichip before its firmware loads
write(os.path.join(usb, "1-1", "idVendor"), "1a6e")
write(os.path.join(usb, "1-1", "idProduct"), "089a")
# and as Google afterwards
write(os.path.join(usb, "1-2", "idVendor"), "18d1")
write(os.path.join(usb, "1-2", "idProduct"), "9302")
coral = self.probe()["edgetpu:usb"]
self.assertEqual(coral.count, 2)
self.assertEqual(coral.units[0].device, "edgetpu:usb:0")
def test_other_usb_devices_are_ignored(self):
usb = os.path.join(self.sys_root, "bus", "usb", "devices")
write(os.path.join(usb, "1-1", "idVendor"), "046d")
write(os.path.join(usb, "1-1", "idProduct"), "0825")
self.assertNotIn("edgetpu:usb", self.probe())
class TestGpus(HardwareProbeTestCase):
def test_a_single_intel_gpu_is_the_unnumbered_device(self):
self.drm.return_value = {"0000:00:02.0": "i915"}
gpu = self.probe()["openvino:GPU"]
self.assertEqual([unit.device for unit in gpu.units], ["openvino:GPU"])
self.assertTrue(gpu.unlimited)
def test_multiple_intel_gpus_are_numbered(self):
self.drm.return_value = {"0000:00:02.0": "i915", "0000:03:00.0": "xe"}
gpu = self.probe()["openvino:GPU"]
self.assertEqual(
[unit.device for unit in gpu.units],
["openvino:GPU.0", "openvino:GPU.1"],
)
def test_non_gpu_drm_devices_are_ignored(self):
self.drm.return_value = {"0000:00:02.0": "virtio-mmio"}
self.assertNotIn("openvino:GPU", self.probe())
def test_amd_gpus_run_through_onnx(self):
self.drm.return_value = {"0000:03:00.0": "amdgpu"}
self.assertEqual(self.probe()["onnx:amd"].units[0].device, "onnx")
def test_an_intel_npu_is_found_by_its_driver(self):
accel = os.path.join(self.sys_root, "class", "accel", "accel0", "device")
os.makedirs(accel)
os.symlink("/drivers/intel_vpu", os.path.join(accel, "driver"))
self.assertEqual(self.probe()["openvino:NPU"].units[0].device, "openvino:NPU")
def test_other_accel_devices_are_ignored(self):
accel = os.path.join(self.sys_root, "class", "accel", "accel0", "device")
os.makedirs(accel)
os.symlink("/drivers/something_else", os.path.join(accel, "driver"))
self.assertNotIn("openvino:NPU", self.probe())
class TestNvidia(HardwareProbeTestCase):
def _add_gpu(self, address: str, model: str) -> None:
write(
os.path.join(
self.proc_root, "driver", "nvidia", "gpus", address, "information"
),
f"Model: \t {model}\nIRQ: \t 62\n",
)
def test_the_model_name_is_read_from_proc(self):
self._add_gpu("0000:01:00.0", "NVIDIA GeForce RTX 3060")
gpu = self.probe()["onnx:nvidia"]
self.assertEqual(gpu.name, "NVIDIA GeForce RTX 3060")
self.assertEqual(gpu.units[0].device, "onnx:0")
def test_multiple_gpus_are_indexed(self):
self._add_gpu("0000:01:00.0", "NVIDIA GeForce RTX 3060")
self._add_gpu("0000:02:00.0", "NVIDIA GeForce RTX 4090")
gpu = self.probe()["onnx:nvidia"]
self.assertEqual(gpu.name, "NVIDIA GPU")
self.assertEqual([unit.device for unit in gpu.units], ["onnx:0", "onnx:1"])
self.assertEqual(gpu.units[1].label, "NVIDIA GeForce RTX 4090")
def test_a_jetson_runs_through_tensorrt(self):
write(os.path.join(self.etc_root, "nv_tegra_release"), "# R36 (release)")
self.assertEqual(self.probe()["tensorrt"].units[0].device, "tensorrt:0")
class TestAccelerators(HardwareProbeTestCase):
def test_hailo_is_found_by_its_device_node(self):
write(os.path.join(self.dev_root, "hailo0"))
self.assertEqual(self.probe()["hailo8l"].units[0].device, "hailo8l:PCIe")
def test_each_memryx_node_is_a_unit(self):
write(os.path.join(self.dev_root, "memx0"))
write(os.path.join(self.dev_root, "memx1"))
memryx = self.probe()["memryx"]
self.assertEqual(
[unit.device for unit in memryx.units],
["memryx:PCIe:0", "memryx:PCIe:1"],
)
self.assertFalse(memryx.unlimited)
def test_a_supported_rockchip_soc_is_reported(self):
write(
os.path.join(self.proc_root, "device-tree", "compatible"),
"rockchip,rk3588\x00",
)
self.assertEqual(self.probe()["rknn"].units[0].device, "rknn")
def test_an_unsupported_soc_is_ignored(self):
write(
os.path.join(self.proc_root, "device-tree", "compatible"),
"nvidia,tegra\x00",
)
self.assertNotIn("rknn", self.probe())
def test_axengine_is_found_by_its_control_node(self):
write(os.path.join(self.dev_root, "axcl_host"))
self.assertEqual(self.probe()["axengine"].units[0].device, "axengine")
def test_synaptics_is_found_by_its_device_node(self):
write(os.path.join(self.dev_root, "synap"))
self.assertEqual(self.probe()["synaptics"].units[0].device, "synaptics")
class TestProber(HardwareProbeTestCase):
def test_the_result_is_cached_until_refreshed(self):
prober = HardwareProber()
self.assertNotIn("edgetpu:pci", {found.key for found in prober.probe()})
os.makedirs(os.path.join(self.sys_root, "class", "apex", "apex_0"))
self.assertNotIn("edgetpu:pci", {found.key for found in prober.probe()})
self.assertIn(
"edgetpu:pci", {found.key for found in prober.probe(refresh=True)}
)
def test_a_failing_probe_does_not_break_the_rest(self):
with patch.object(hardware, "detect_hailo", side_effect=OSError("boom")):
self.assertIn("cpu", self.probe())
def test_unlimited_tracks_the_detector_shareable_flag(self):
for name in ("apex_0",):
os.makedirs(os.path.join(self.sys_root, "class", "apex", name))
write(os.path.join(self.dev_root, "memx0"))
self.drm.return_value = {"0000:00:02.0": "i915"}
for found in self.probe().values():
config_class = config_types.get(found.detector)
if config_class is None:
continue
with self.subTest(hardware=found.key):
self.assertEqual(found.unlimited, config_class.shareable)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -108,7 +108,7 @@ class TestGpuStats(unittest.TestCase):
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services.time.monotonic")
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
@patch("frigate.util.services.enumerate_drm_devices")
def test_intel_gpu_stats_fdinfo(
self, drm_devices, read_fdinfo, monotonic, sleep, get_names
):
@ -187,7 +187,7 @@ class TestGpuStats(unittest.TestCase):
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services.time.monotonic")
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
@patch("frigate.util.services.enumerate_drm_devices")
def test_intel_gpu_stats_xe_capacity(
self, drm_devices, read_fdinfo, monotonic, sleep, get_names
):
@ -246,7 +246,7 @@ class TestGpuStats(unittest.TestCase):
@patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
@patch("frigate.util.services.enumerate_drm_devices")
def test_intel_gpu_stats_no_clients_reports_idle(
self, drm_devices, read_fdinfo, sleep, get_names
):
@ -274,7 +274,7 @@ class TestGpuStats(unittest.TestCase):
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
@patch("frigate.util.services.enumerate_drm_devices")
def test_intel_gpu_stats_clients_without_engine_counters(
self, drm_devices, read_fdinfo, sleep
):
@ -301,7 +301,7 @@ class TestGpuStats(unittest.TestCase):
read_fdinfo.assert_called_once()
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
@patch("frigate.util.services.enumerate_drm_devices")
def test_intel_gpu_stats_no_intel_device(self, drm_devices, read_fdinfo):
# Only a non-Intel GPU is visible in sysfs; /proc is never scanned
drm_devices.return_value = {"0000:01:00.0": "nvidia"}
@ -310,7 +310,7 @@ class TestGpuStats(unittest.TestCase):
read_fdinfo.assert_not_called()
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
@patch("frigate.util.services.enumerate_drm_devices")
@patch("frigate.util.services._resolve_intel_gpu_pdev")
def test_intel_gpu_stats_unresolvable_device_hint(
self, resolve_pdev, drm_devices, read_fdinfo
@ -324,7 +324,7 @@ class TestGpuStats(unittest.TestCase):
read_fdinfo.assert_not_called()
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
@patch("frigate.util.services.enumerate_drm_devices")
@patch("frigate.util.services._resolve_intel_gpu_pdev")
def test_intel_gpu_stats_hint_resolves_to_non_intel_gpu(
self, resolve_pdev, drm_devices, read_fdinfo
@ -342,7 +342,7 @@ class TestGpuStats(unittest.TestCase):
read_fdinfo.assert_not_called()
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
@patch("frigate.util.services.enumerate_drm_devices")
def test_intel_gpu_stats_unreadable_proc(self, drm_devices, read_fdinfo):
# A scan failure (None) is a different condition than a scan that
# finds no clients ({}) and must not report idle
@ -355,7 +355,7 @@ class TestGpuStats(unittest.TestCase):
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services.time.monotonic")
@patch("frigate.util.services._read_intel_drm_fdinfo")
@patch("frigate.util.services._enumerate_drm_devices")
@patch("frigate.util.services.enumerate_drm_devices")
def test_intel_gpu_stats_clients_lost_between_samples(
self, drm_devices, read_fdinfo, monotonic, sleep, get_names
):

View File

@ -315,7 +315,7 @@ def _resolve_intel_gpu_pdev(device: str | None) -> str | None:
return pdev if _PCI_ADDRESS_RE.match(pdev) else None
def _enumerate_drm_devices() -> dict[str, str]:
def enumerate_drm_devices() -> dict[str, str]:
"""Map each PCI-attached DRM device to its bound kernel driver.
Reads /sys/class/drm, which reflects every GPU on the host even when only
@ -517,7 +517,7 @@ def get_intel_gpu_stats(
)
return None
drm_devices = _enumerate_drm_devices()
drm_devices = enumerate_drm_devices()
intel_pdevs = {
pdev: driver
for pdev, driver in drm_devices.items()

View File

@ -56,6 +56,7 @@ from frigate.api import (
debug_replay,
event,
export,
hardware,
media,
motion_search,
notification,
@ -152,6 +153,7 @@ def build_app() -> FastAPI:
preview.router,
notification.router,
export.router,
hardware.router,
event.router,
media.router,
motion_search.router,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,39 @@
/**
* Detection hardware as reported by GET /api/hardware/probe.
*
* A mixed payload on purpose: two Corals exercise the per-unit checkboxes and
* the "already used by another model" state, while the Intel GPU exercises the
* unlimited detector-count dropdown.
*/
export const DETECTION_HARDWARE = [
{
key: "edgetpu:pci",
detector: "edgetpu",
name: "Coral EdgeTPU (PCIe)",
units: [
{ device: "edgetpu:pci:0", label: "PCIe 0" },
{ device: "edgetpu:pci:1", label: "PCIe 1" },
],
count: 2,
unlimited: false,
},
{
key: "openvino:GPU",
detector: "openvino",
name: "Intel GPU",
units: [
{ device: "openvino:GPU.0", label: "0000:00:02.0" },
{ device: "openvino:GPU.1", label: "0000:03:00.0" },
],
count: 2,
unlimited: true,
},
{
key: "cpu",
detector: "cpu",
name: "CPU",
units: [{ device: "cpu", label: "CPU" }],
count: 1,
unlimited: true,
},
];

View File

@ -14,6 +14,7 @@ import {
type DeepPartial,
configFactory,
} from "../fixtures/mock-data/config";
import { DETECTION_HARDWARE } from "../fixtures/mock-data/hardware";
import { adminProfile, type UserProfile } from "../fixtures/mock-data/profile";
import { BASE_STATS, statsFactory } from "../fixtures/mock-data/stats";
@ -41,6 +42,7 @@ export interface ApiMockOverrides {
faces?: Record<string, unknown>;
configRaw?: string;
configSchema?: Record<string, unknown>;
hardware?: unknown[];
}
export class ApiMocker {
@ -178,6 +180,11 @@ export class ApiMocker {
route.fulfill({ json: { success: true, require_restart: false } }),
);
// Detection hardware discovery
await this.page.route("**/api/hardware/probe**", (route) =>
route.fulfill({ json: overrides?.hardware ?? DETECTION_HARDWARE }),
);
// Go2RTC streams
await this.page.route("**/api/go2rtc/streams**", (route) =>
route.fulfill({ json: {} }),

View File

@ -0,0 +1,404 @@
/**
* Detection models settings page tests -- HIGH tier.
*
* Covers picking hardware per model: exclusive units (Corals) are checkboxes
* that can only be claimed by one model, unlimited hardware (a GPU) gets a
* detector-count dropdown, and the whole models list saves in one PUT.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const PAGE = "/settings?page=systemDetectorsAndModel";
type Model = {
scene: string;
devices: string[];
path?: string | null;
input_tensor?: string;
input_pixel_format?: string;
input_dtype?: string;
model_type?: string;
labelmap?: Record<string, string>;
attributes_map?: Record<string, string[]>;
plus?: { id: string; name: string } | null;
width?: number;
height?: number;
};
const PLUS_MODEL = {
id: "abc123",
name: "yolov9-s",
baseModel: "yolov9",
trainDate: "2026-01-02T03:04:05Z",
isBaseModel: true,
supportedDetectors: ["openvino"],
width: 320,
height: 320,
};
type SavedConfig = { config_data?: { models?: Model[] } };
async function installRoutes(page: Page, models: Model[], plusEnabled = false) {
const config = configFactory({
models,
plus: { enabled: plusEnabled },
} as never);
const saves: SavedConfig[] = [];
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) =>
route.request().method() === "GET"
? route.fulfill({ json: config })
: route.fulfill({ json: { success: true } }),
);
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: { models } }),
);
await page.route("**/api/plus/models", (route) =>
route.fulfill({ json: [PLUS_MODEL] }),
);
await page.route("**/api/config/set", async (route) => {
saves.push(route.request().postDataJSON() as SavedConfig);
await route.fulfill({ json: { success: true, require_restart: false } });
});
return saves;
}
const openPage = async (frigateApp: {
goto: (url: string) => Promise<void>;
}) => {
await frigateApp.goto(PAGE);
};
test.describe("Detection models settings @high", () => {
test("renders a card per configured model", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["cpu"] },
{ scene: "outdoor", devices: ["edgetpu:pci:0"] },
]);
await openPage(frigateApp);
const root = frigateApp.page.locator("#pageRoot");
await expect(root).toContainText("All cameras");
await expect(root).toContainText("Outdoor");
});
test("unlimited hardware offers a detector count", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0"] },
]);
await openPage(frigateApp);
await expect(
frigateApp.page.getByText("Detectors", { exact: true }),
).toBeVisible();
// the trigger shows the bare count; the recommendation is a second line on
// the matching option, so the dropdown has to be open to see it
await expect(
frigateApp.page.locator("#models-0-detector-count"),
).toHaveText("1");
await frigateApp.page.locator("#models-0-detector-count").click();
// three cameras in the mock config, so one detector is recommended
await expect(
frigateApp.page.getByRole("option", {
name: /Recommended for 3 cameras/,
}),
).toHaveText(/^1/);
});
test("a detector count above the recommendation is unlabelled", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.0"] },
]);
await openPage(frigateApp);
// two detectors are configured while one is recommended, so neither the
// trigger nor the selected option carries a recommendation
await expect(
frigateApp.page.locator("#models-0-detector-count"),
).toHaveText("2");
await frigateApp.page.locator("#models-0-detector-count").click();
await expect(
frigateApp.page.getByRole("option", { name: /^2/ }),
).not.toContainText("Recommended");
});
test("exclusive hardware offers one checkbox per unit", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["edgetpu:pci:0"] },
]);
await openPage(frigateApp);
await expect(
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:0"),
).toBeChecked();
await expect(
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:1"),
).not.toBeChecked();
});
test("a unit claimed by another model cannot be picked", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["edgetpu:pci:0"] },
{ scene: "outdoor", devices: ["edgetpu:pci:1"] },
]);
await openPage(frigateApp);
// the first card's checkbox for the unit the second model holds
await expect(
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:1").first(),
).toBeDisabled();
});
test("adding a model appends a card with an unused scene", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [{ scene: "all", devices: ["cpu"] }]);
await openPage(frigateApp);
await frigateApp.page.getByRole("button", { name: "Add model" }).click();
// "all" is taken, so the new card takes the next available scene
await expect(frigateApp.page.locator("#pageRoot")).toContainText("Indoor");
});
test("hardware is summarized rather than listed device by device", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.0"] },
]);
await openPage(frigateApp);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"Intel GPU (2) \u2022 3 cameras",
);
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
"openvino:GPU, openvino:GPU",
);
});
test("a saved Frigate+ model opens on the Frigate+ tab", async ({
frigateApp,
}) => {
// the backend resolves plus:// to a cache path before serving the config
// back, so the plus metadata is the only signal the model is a Plus one
await installRoutes(
frigateApp.page,
[
{
scene: "all",
devices: ["openvino:GPU.0"],
path: "/config/model_cache/abc123",
plus: PLUS_MODEL,
},
],
true,
);
await openPage(frigateApp);
await expect(
frigateApp.page.getByRole("tab", { name: "Frigate+" }),
).toHaveAttribute("data-state", "active");
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"yolov9-s",
);
});
test("picking a Frigate+ model stays on the tab and saves a plus path", async ({
frigateApp,
}) => {
const saves = await installRoutes(
frigateApp.page,
[
{
scene: "all",
devices: ["openvino:GPU.0"],
path: "/config/custom.onnx",
},
],
true,
);
await openPage(frigateApp);
await frigateApp.page.getByRole("tab", { name: "Frigate+" }).click();
await frigateApp.page.getByRole("combobox").last().click();
await frigateApp.page.getByRole("option").first().click();
await expect(
frigateApp.page.getByRole("tab", { name: "Frigate+" }),
).toHaveAttribute("data-state", "active");
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
await expect.poll(() => saves.length).toBeGreaterThan(0);
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
});
test("a freshly opened page is not reported as modified", async ({
frigateApp,
}) => {
// `/api/config` serializes with exclude_none, so a nullable field such as
// labelmap_path is absent rather than null. The form materializes it, and
// that must not read as an edit.
await installRoutes(frigateApp.page, [
{
scene: "all",
devices: ["openvino:GPU.0", "openvino:GPU.0"],
path: "/config/model_cache/abc123",
width: 320,
height: 320,
input_tensor: "nchw",
input_pixel_format: "rgb",
input_dtype: "float",
model_type: "yolo-generic",
labelmap: {},
attributes_map: {},
},
]);
await openPage(frigateApp);
await expect(
frigateApp.page.getByRole("button", { name: /^Save$/ }),
).toBeVisible();
await expect(frigateApp.page.getByText("Modified")).toHaveCount(0);
});
test("the scene, hardware and detector count fields are described", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0"] },
]);
await openPage(frigateApp);
const root = frigateApp.page.locator("#pageRoot");
await expect(root).toContainText("The environment this model is for");
await expect(root).toContainText(
"The hardware this model runs its detection on",
);
await expect(root).toContainText("How many detection processes to run");
});
test("per unit hardware explains why a claimed unit is unavailable", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["edgetpu:pci:0"] },
]);
await openPage(frigateApp);
// the count dropdown is replaced by checkboxes, so it gets its own copy
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"Each unit runs its own detection process",
);
});
test("removing the default model blocks saving", async ({ frigateApp }) => {
// a camera that names no scene runs the "all" model, so deleting it would
// leave those cameras with nothing to fall back to
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["cpu"] },
{ scene: "outdoor", devices: ["edgetpu:pci:0"] },
]);
await openPage(frigateApp);
await frigateApp.page
.getByRole("button", { name: "Delete" })
.first()
.click();
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"One model must use a scene of 'All cameras'",
);
await expect(
frigateApp.page.getByRole("button", { name: /^Save$/ }),
).toBeDisabled();
});
test("a second GPU can be assigned to a model", async ({ frigateApp }) => {
// shareable hardware can report several addressable units; every one of
// them must be reachable, not just the first
const saves = await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0"] },
]);
await openPage(frigateApp);
await frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").click();
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
await expect.poll(() => saves.length).toBeGreaterThan(0);
expect(saves.at(-1)?.config_data?.models?.[0].devices).toEqual([
"openvino:GPU.0",
"openvino:GPU.1",
]);
});
test("detectors are spread across every selected GPU", async ({
frigateApp,
}) => {
const saves = await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.1"] },
]);
await openPage(frigateApp);
await frigateApp.page.locator("#models-0-detector-count").click();
await frigateApp.page
.getByRole("option", { name: "4", exact: true })
.click();
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
await expect.poll(() => saves.length).toBeGreaterThan(0);
expect(saves.at(-1)?.config_data?.models?.[0].devices).toEqual([
"openvino:GPU.0",
"openvino:GPU.1",
"openvino:GPU.0",
"openvino:GPU.1",
]);
});
test("saving writes the whole models list in one request", async ({
frigateApp,
}) => {
const saves = await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["edgetpu:pci:0"] },
]);
await openPage(frigateApp);
await frigateApp.page.locator("#models-0-edgetpu\\:pci\\:1").click();
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
await expect.poll(() => saves.length).toBeGreaterThan(0);
const models = saves.at(-1)?.config_data?.models;
expect(models).toHaveLength(1);
expect(models?.[0].devices).toEqual(["edgetpu:pci:0", "edgetpu:pci:1"]);
});
});

View File

@ -1,58 +0,0 @@
/**
* Detectors and model settings page tests -- HIGH tier.
*
* Tests rendering of the merged page and navigation from the Frigate+ page.
*/
import { test, expect } from "../../fixtures/frigate-test";
// The settings page still reads the removed `detectors` and `model` config
// keys, so it cannot render against a `models` config. Re-enable these once
// the page is rebuilt around the models list.
test.describe.skip("Detectors and model Settings @high", () => {
test("page renders with detector and model cards", async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=systemDetectorsAndModel");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
const text = await frigateApp.page.textContent("#pageRoot");
expect(text).toContain("Detectors and model");
expect(text?.toLowerCase()).toContain("detector hardware");
expect(text?.toLowerCase()).toContain("detection model");
});
test("Frigate+ page links to the merged page", async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=frigateplus");
await frigateApp.page.waitForTimeout(2000);
const button = frigateApp.page.getByRole("button", {
name: /Change in Detectors and model/,
});
// Button only appears when Frigate+ is enabled in the test config; skip
// the click assertion if it's not present.
if ((await button.count()) > 0) {
await button.first().click();
await frigateApp.page.waitForURL(/page=systemDetectorsAndModel/);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"Detectors and model",
);
} else {
test.skip(
true,
"Frigate+ not enabled in this test config; skipping link assertion",
);
}
});
test("old systemDetectionModel deep-link no longer routes here", async ({
frigateApp,
}) => {
await frigateApp.goto("/settings?page=systemDetectionModel");
await frigateApp.page.waitForTimeout(2000);
// The old page key is no longer in allSettingsViews; the router
// falls back to its default settings page (uiSettings).
const text = await frigateApp.page.textContent("#pageRoot");
expect(text).not.toContain("Detection model");
});
});

View File

@ -129,6 +129,8 @@
"saving": "Saving…",
"cancel": "Cancel",
"close": "Close",
"expand": "Expand",
"collapse": "Collapse",
"copy": "Copy",
"copiedToClipboard": "Copied to clipboard",
"back": "Back",

View File

@ -100,7 +100,7 @@
},
"scene": {
"label": "Detect scene",
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Defaults to the model with a scene of 'all'."
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'."
},
"fps": {
"label": "Detect FPS",

View File

@ -468,7 +468,7 @@
},
"scene": {
"label": "Detect scene",
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Defaults to the model with a scene of 'all'."
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'."
},
"fps": {
"label": "Detect FPS",

View File

@ -31,5 +31,8 @@
},
"detect": {
"dimensionMustBeEven": "Must be an even number."
},
"models": {
"defaultRequired": "One model must use a scene of 'All cameras'. Without it, any camera that does not choose a scene has no model to fall back to."
}
}

View File

@ -75,7 +75,7 @@
"systemTelemetry": "Telemetry",
"systemBirdseye": "Birdseye",
"systemFfmpeg": "FFmpeg",
"systemDetectorsAndModel": "Detectors and model",
"systemDetectorsAndModel": "Detection models",
"systemMqtt": "MQTT",
"systemGo2rtcStreams": "go2rtc streams",
"integrationSemanticSearch": "Semantic search",
@ -1272,31 +1272,6 @@
"error": "Failed to save config changes: {{errorMessage}}"
}
},
"detectorsAndModel": {
"title": "Detectors and model",
"description": "Configure the detector backend that runs object detection and the model it uses. Changes are saved together so the detector and model stay in sync.",
"cardTitles": {
"detector": "Detector Hardware",
"model": "Detection Model"
},
"tabs": {
"plus": "Frigate+",
"custom": "Custom Model"
},
"mismatch": {
"warning": "The current Frigate+ model \"{{model}}\" requires the {{required}} detector. Pick a compatible model below or switch to Custom Model before saving."
},
"plusModel": {
"requiresDetector": "Requires: {{detector}}",
"noModelSelected": "Select a Frigate+ model"
},
"toast": {
"saveSuccess": "Detectors and model settings saved. Restart Frigate to apply changes.",
"saveError": "Failed to save detector and model settings"
},
"unsavedChanges": "Unsaved detector and model changes",
"restartRequired": "Restart required (detector or model changed)"
},
"triggers": {
"documentTitle": "Triggers",
"semanticSearch": {
@ -1616,16 +1591,6 @@
"detect": {
"title": "Detection Settings"
},
"detectors": {
"title": "Detector Settings",
"singleType": "Only one {{type}} detector is allowed.",
"keyRequired": "Detector name is required.",
"keyDuplicate": "Detector name already exists.",
"noSchema": "No detector schemas are available.",
"none": "No detector instances configured.",
"add": "Add detector",
"addCustomKey": "Add custom key"
},
"record": {
"title": "Recording Settings"
},
@ -1943,6 +1908,7 @@
"detect": {
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
"sceneWithoutModel": "No detection model is configured for this scene, so this camera falls back to the model with a scene of 'All cameras'. Add a model for this scene to give the camera its own.",
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image.",
"maxFramesSet": "Setting max frames overrides default behavior and disables stationary object tracking. There are very few situations where this is needed, use with caution.",
@ -1981,15 +1947,50 @@
"snapshots": {
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created."
},
"detectors": {
"mixedTypes": "All detectors must use the same type. Remove existing detectors to use a different type.",
"mixedTypesSuggestion": "All detectors must use the same type. Remove existing detectors or select {{type}}."
},
"semanticSearch": {
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
},
"onvif": {
"autotrackingNoZones": "Autotracking requires at least one zone. Define a zone for this camera in Masks / Zones, then set it as a required zone below."
}
},
"detectionModels": {
"title": "Detection models",
"description": "Configure the object detection models and the hardware each one runs on. Cameras choose a model by their scene in the camera's detect settings.",
"addModel": "Add model",
"cameras_one": "{{count}} camera",
"cameras_other": "{{count}} cameras",
"scene": {
"label": "Scene",
"description": "The environment this model is for. Cameras pick a model by setting the same scene in their detect settings, and the model with a scene of all is used by any camera that does not set one."
},
"scenes": {
"all": "All cameras",
"indoor": "Indoor",
"outdoor": "Outdoor",
"indoor_thermal": "Indoor thermal",
"outdoor_thermal": "Outdoor thermal"
},
"hardware": {
"label": "Hardware",
"placeholder": "Select hardware",
"loading": "Looking for detection hardware...",
"none": "No hardware selected",
"claimedBy": "used by {{scene}}",
"detectorCount": "Detectors",
"countRecommended_one": "Recommended for {{count}} camera",
"countRecommended_other": "Recommended for {{count}} cameras",
"unrecognized": "This model is configured for hardware that was not found on this system: {{devices}}",
"description": "The hardware this model runs its detection on.",
"detectorCountDescription": "How many detection processes to run on this hardware. More detectors keep up with more cameras, at the cost of extra device memory.",
"unitsDescription": "Each unit runs its own detection process. A unit already used by another model can not be selected."
},
"tabs": {
"plus": "Frigate+",
"custom": "Custom Model"
},
"plusModel": {
"noModelSelected": "Select a Frigate+ model"
}
}
}

View File

@ -110,6 +110,21 @@ const detect: SectionConfigOverrides = {
return Math.abs(newRatio - savedRatio) > 0.01;
},
},
{
key: "detect-scene-without-model",
field: "scene",
position: "after",
messageKey: "configMessages.detect.sceneWithoutModel",
severity: "warning",
docLink: "/configuration/object_detectors#running-more-than-one-model",
condition: (ctx) => {
const scene = ctx.formData?.scene as string | undefined;
if (!scene || scene === "all") return false;
const models = ctx.fullConfig?.models;
if (!models) return false;
return !models.some((model) => model.scene === scene);
},
},
{
key: "fps-greater-than-five",
field: "fps",
@ -153,6 +168,7 @@ const detect: SectionConfigOverrides = {
],
fieldOrder: [
"enabled",
"scene",
"width",
"height",
"fps",
@ -170,6 +186,11 @@ const detect: SectionConfigOverrides = {
tracking: ["min_initialized", "max_disappeared"],
},
uiSchema: {
scene: {
"ui:options": {
enumI18nPrefix: "detectionModels.scenes",
},
},
annotation_offset: {
"ui:options": {
signed: true,
@ -186,6 +207,7 @@ const detect: SectionConfigOverrides = {
},
global: {
restartRequired: [
"scene",
"fps",
"width",
"height",
@ -195,6 +217,7 @@ const detect: SectionConfigOverrides = {
},
camera: {
restartRequired: [
"scene",
"fps",
"width",
"height",
@ -211,6 +234,7 @@ const detect: SectionConfigOverrides = {
hiddenFields: [
"enabled",
"enabled_in_config",
"scene",
"min_initialized",
"max_disappeared",
"annotation_offset",

View File

@ -1,28 +0,0 @@
import type { SectionConfigOverrides } from "./types";
const detectorHiddenFields = [
"*.model.labelmap",
"*.model.attributes_map",
"*.model",
"*.model_path",
];
const detectors: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_detectors",
fieldOrder: [],
advancedFields: [],
hiddenFields: detectorHiddenFields,
uiSchema: {
"ui:field": "DetectorHardwareField",
"ui:options": {
multiInstanceTypes: ["cpu", "onnx", "openvino", "edgetpu"],
typeOrder: ["onnx", "openvino", "edgetpu"],
hiddenByType: {},
hiddenFields: detectorHiddenFields,
},
},
},
};
export default detectors;

View File

@ -1,8 +1,24 @@
import type { SectionConfigOverrides } from "./types";
const model: SectionConfigOverrides = {
// scene and devices are rendered by ModelsField itself; the rest of each model
// is delegated back to the schema form
const modelFields = [
"path",
"labelmap_path",
"width",
"height",
"input_pixel_format",
"input_tensor",
"input_dtype",
"model_type",
];
const models: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_detectors#model",
sectionDocs: "/configuration/object_detectors",
// the default-model rule must be enforced as the list is edited, not
// only when the form is submitted
liveValidate: true,
fieldMessages: [
{
key: "model-optimized-for-320",
@ -36,51 +52,46 @@ const model: SectionConfigOverrides = {
},
},
],
// every model field takes effect only when the detection processes restart
restartRequired: [
"path",
"labelmap_path",
"width",
"height",
"scene",
"devices",
...modelFields,
"labelmap",
"attributes_map",
"input_tensor",
"input_pixel_format",
"input_dtype",
"model_type",
],
fieldOrder: [
"path",
"labelmap_path",
"width",
"height",
"input_pixel_format",
"input_tensor",
"input_dtype",
"model_type",
],
advancedFields: [
"input_pixel_format",
"input_tensor",
"input_dtype",
"model_type",
],
].map((field) => `*.${field}`),
hiddenFields: [
"labelmap",
"attributes_map",
"colormap",
"all_attributes",
"non_logo_attributes",
"plus",
"*.labelmap",
"*.attributes_map",
"*.colormap",
"*.all_attributes",
"*.non_logo_attributes",
"*.plus",
],
uiSchema: {
path: {
"ui:options": { size: "md" },
},
labelmap_path: {
"ui:options": { size: "md" },
"ui:field": "ModelsField",
items: {
path: {
"ui:options": { size: "md" },
},
labelmap_path: {
"ui:options": { size: "md" },
},
input_pixel_format: {
"ui:options": { advanced: true, size: "xs" },
},
input_tensor: {
"ui:options": { advanced: true, size: "xs" },
},
input_dtype: {
"ui:options": { advanced: true, size: "xs" },
},
model_type: {
"ui:options": { advanced: true, size: "xs" },
},
},
},
},
};
export default model;
export default models;

View File

@ -2,6 +2,7 @@ import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { validateDetectDimensions } from "./detect";
import { validateFfmpegInputRoles } from "./ffmpeg";
import { validateDefaultModelExists } from "./models";
import { validateProxyRoleHeader } from "./proxy";
export type SectionValidation = (
@ -28,6 +29,11 @@ export function getSectionValidation({
return (formData, errors) => validateFfmpegInputRoles(formData, errors, t);
}
if (sectionPath === "models") {
return (formData, errors) =>
validateDefaultModelExists(formData, errors, t);
}
if (sectionPath === "proxy" && level === "global") {
return (formData, errors) => validateProxyRoleHeader(formData, errors, t);
}

View File

@ -0,0 +1,31 @@
import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { isJsonObject } from "@/lib/utils";
const DEFAULT_SCENE = "all";
/**
* A camera that names no scene runs the model whose scene is `all`. Without one
* the backend rejects the config outright once a second model exists, and with
* a single model it silently runs every camera on whatever that model is. Both
* are surprising, so require the default to be present.
*/
export function validateDefaultModelExists(
formData: unknown,
errors: FormValidation,
t: TFunction,
): FormValidation {
if (!Array.isArray(formData) || formData.length === 0) {
return errors;
}
const hasDefault = formData.some(
(model) => isJsonObject(model) && model.scene === DEFAULT_SCENE,
);
if (!hasDefault) {
errors.addError?.(t("models.defaultRequired", { ns: "config/validation" }));
}
return errors;
}

View File

@ -23,7 +23,6 @@ import birdseye from "./section-configs/birdseye";
import classification from "./section-configs/classification";
import database from "./section-configs/database";
import detect from "./section-configs/detect";
import detectors from "./section-configs/detectors";
import environmentVars from "./section-configs/environment_vars";
import faceRecognition from "./section-configs/face_recognition";
import ffmpeg from "./section-configs/ffmpeg";
@ -31,7 +30,7 @@ import genai from "./section-configs/genai";
import live from "./section-configs/live";
import logger from "./section-configs/logger";
import lpr from "./section-configs/lpr";
import model from "./section-configs/model";
import models from "./section-configs/models";
import motion from "./section-configs/motion";
import mqtt from "./section-configs/mqtt";
import networking from "./section-configs/networking";
@ -76,8 +75,7 @@ export const sectionConfigs: Record<string, SectionConfigOverrides> = {
logger,
environment_vars: environmentVars,
telemetry,
detectors,
model,
models,
genai,
classification,
};

View File

@ -72,8 +72,7 @@ const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
"environment_vars",
"telemetry",
"birdseye",
"detectors",
"model",
"models",
]);
type CameraEntryProps = {

View File

@ -16,7 +16,7 @@ import { getEffectiveAttributeLabels } from "@/utils/configUtil";
* Sections that require special handling at the global level.
* Add new section paths here as needed.
*/
const SPECIAL_CASE_SECTIONS = ["motion", "detectors", "genai"] as const;
const SPECIAL_CASE_SECTIONS = ["motion", "genai"] as const;
/**
* Check if a section requires special case handling.
@ -36,8 +36,6 @@ export function isSpecialCaseSection(
/**
* Modify schema for sections that need defaults stripped or other modifications.
*
* - detectors: Strip the "default" field to prevent RJSF from merging the
* default {"cpu": {"type": "cpu"}} with stored detector keys.
* - genai: Inject a default provider value on the additionalProperties shape.
* - objects: Promote tracked attribute labels (face, license_plate, courier
* logos) from `filters.additionalProperties` to explicit
@ -63,12 +61,6 @@ export function modifySchemaForSection(
return schema;
}
// detectors: Remove default to prevent merging with stored keys
if (sectionPath === "detectors" && "default" in schema) {
const { default: _, ...schemaWithoutDefault } = schema;
return schemaWithoutDefault;
}
if (sectionPath === "genai") {
const additional = schema.additionalProperties;
if (
@ -270,8 +262,6 @@ function modifyObjectsSchema(
* - motion: Has anyOf schema with [null, MotionConfig]. When stored value is
* null, derive defaults from the non-null anyOf branch to avoid showing
* changes when navigating to the page.
* - detectors: Return empty object since the schema default would add unwanted
* keys to the stored configuration.
*/
export function getEffectiveDefaultsForSection(
sectionPath: string,
@ -305,11 +295,6 @@ export function getEffectiveDefaultsForSection(
return applySchemaDefaults(motionSchema as RJSFSchema, {});
}
// detectors: Return empty object to avoid adding default keys
if (sectionPath === "detectors") {
return {};
}
return schemaDefaults;
}
@ -424,27 +409,6 @@ export function sanitizeOverridesForSection(
return flattened;
};
// detectors: Strip readonly model fields that are generated on startup
// and should never be persisted back to the config file.
if (sectionPath === "detectors") {
const overridesObj = overrides as JsonObject;
const cleaned: JsonObject = {};
Object.entries(overridesObj).forEach(([key, value]) => {
if (!isJsonObject(value)) {
cleaned[key] = value;
return;
}
const cleanedValue = { ...value } as JsonObject;
delete cleanedValue.model;
delete cleanedValue.model_path;
cleaned[key] = cleanedValue;
});
return cleaned;
}
if (sectionPath === "logger") {
const overridesObj = overrides as JsonObject;
const logs = overridesObj.logs;

View File

@ -1,956 +0,0 @@
import type {
ErrorSchema,
FieldPathList,
FieldProps,
RJSFSchema,
UiSchema,
} from "@rjsf/utils";
import { toFieldPathId } from "@rjsf/utils";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LuChevronDown,
LuChevronRight,
LuPlus,
LuTrash2,
} from "react-icons/lu";
import { applySchemaDefaults } from "@/lib/config-schema";
import { cn, isJsonObject, mergeUiSchema } from "@/lib/utils";
import { ConfigFormContext, JsonObject } from "@/types/configForm";
import { requiresRestartForFieldPath } from "@/utils/configUtil";
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { humanizeKey } from "../utils/i18n";
type DetectorHardwareFieldOptions = {
multiInstanceTypes?: string[];
hiddenByType?: Record<string, string[]>;
hiddenFields?: string[];
typeOrder?: string[];
};
type DetectorSchemaEntry = {
type: string;
schema: RJSFSchema;
};
const DEFAULT_MULTI_INSTANCE_TYPES = ["cpu", "onnx", "openvino"];
const EMPTY_HIDDEN_BY_TYPE: Record<string, string[]> = {};
const EMPTY_HIDDEN_FIELDS: string[] = [];
const EMPTY_TYPE_ORDER: string[] = [];
const isSchemaObject = (schema: unknown): schema is RJSFSchema =>
typeof schema === "object" && schema !== null;
const getUnionSchemas = (schema?: RJSFSchema): RJSFSchema[] => {
if (!schema) {
return [];
}
const schemaObj = schema as Record<string, unknown>;
const union = schemaObj.oneOf ?? schemaObj.anyOf;
if (Array.isArray(union)) {
return union.filter(isSchemaObject) as RJSFSchema[];
}
return [schema];
};
const getTypeValues = (schema: RJSFSchema): string[] => {
const schemaObj = schema as Record<string, unknown>;
const properties = schemaObj.properties as
| Record<string, unknown>
| undefined;
const typeSchema = properties?.type as Record<string, unknown> | undefined;
const values: string[] = [];
if (typeof typeSchema?.const === "string") {
values.push(typeSchema.const);
}
if (Array.isArray(typeSchema?.enum)) {
typeSchema.enum.forEach((value) => {
if (typeof value === "string") {
values.push(value);
}
});
}
return values;
};
const buildHiddenUiSchema = (paths: string[]): UiSchema => {
const result: UiSchema = {};
paths.forEach((path) => {
if (!path) {
return;
}
const segments = path.split(".").filter(Boolean);
if (segments.length === 0) {
return;
}
let cursor = result;
segments.forEach((segment, index) => {
if (index === segments.length - 1) {
cursor[segment] = {
...(cursor[segment] as UiSchema | undefined),
"ui:widget": "hidden",
} as UiSchema;
return;
}
const existing = (cursor[segment] as UiSchema | undefined) ?? {};
cursor[segment] = existing;
cursor = existing;
});
});
return result;
};
const getInstanceType = (value: unknown): string | undefined => {
if (!isJsonObject(value)) {
return undefined;
}
const typeValue = value.type;
return typeof typeValue === "string" && typeValue.length > 0
? typeValue
: undefined;
};
export function DetectorHardwareField(props: FieldProps) {
const {
schema,
uiSchema,
registry,
fieldPathId,
formData: rawFormData,
errorSchema,
disabled,
readonly,
hideError,
onBlur,
onFocus,
onChange,
} = props;
const formContext = registry.formContext as ConfigFormContext | undefined;
const configNamespace =
formContext?.i18nNamespace ??
(formContext?.level === "camera" ? "config/cameras" : "config/global");
const { t: fallbackT } = useTranslation(["common", configNamespace]);
const t = formContext?.t ?? fallbackT;
const sectionPrefix = formContext?.sectionI18nPrefix ?? "detectors";
const restartRequired = formContext?.restartRequired;
const defaultRequiresRestart = formContext?.requiresRestart ?? true;
const options =
(uiSchema?.["ui:options"] as DetectorHardwareFieldOptions | undefined) ??
{};
const multiInstanceTypes =
options.multiInstanceTypes ?? DEFAULT_MULTI_INSTANCE_TYPES;
const hiddenByType = options.hiddenByType ?? EMPTY_HIDDEN_BY_TYPE;
const hiddenFields = options.hiddenFields ?? EMPTY_HIDDEN_FIELDS;
const typeOrder = options.typeOrder ?? EMPTY_TYPE_ORDER;
const multiInstanceSet = useMemo(
() => new Set(multiInstanceTypes),
[multiInstanceTypes],
);
const globalHiddenFields = useMemo(
() =>
hiddenFields
.map((path) => (path.startsWith("*.") ? path.slice(2) : path))
.filter((path) => path.length > 0),
[hiddenFields],
);
const detectorConfigSchema = useMemo(() => {
const additional = (schema as RJSFSchema | undefined)?.additionalProperties;
if (isSchemaObject(additional)) {
return additional as RJSFSchema;
}
const rootSchema = registry.rootSchema as Record<string, unknown>;
const defs =
(rootSchema?.$defs as Record<string, unknown> | undefined) ??
(rootSchema?.definitions as Record<string, unknown> | undefined);
const fallback = defs?.DetectorConfig;
return isSchemaObject(fallback) ? (fallback as RJSFSchema) : undefined;
}, [schema, registry.rootSchema]);
const detectorSchemas = useMemo<DetectorSchemaEntry[]>(() => {
const entries: DetectorSchemaEntry[] = [];
getUnionSchemas(detectorConfigSchema).forEach((schema) => {
const types = getTypeValues(schema);
types.forEach((type) => {
entries.push({ type, schema });
});
});
return entries;
}, [detectorConfigSchema]);
const detectorSchemaByType = useMemo(() => {
const map = new Map<string, RJSFSchema>();
detectorSchemas.forEach(({ type, schema }) => {
if (!map.has(type)) {
map.set(type, schema);
}
});
return map;
}, [detectorSchemas]);
const availableTypes = useMemo(
() => detectorSchemas.map((entry) => entry.type),
[detectorSchemas],
);
const orderedTypes = useMemo(() => {
if (!typeOrder.length) {
return availableTypes;
}
const availableSet = new Set(availableTypes);
const ordered = typeOrder.filter((type) => availableSet.has(type));
const orderedSet = new Set(ordered);
const remaining = availableTypes.filter((type) => !orderedSet.has(type));
return [...ordered, ...remaining];
}, [availableTypes, typeOrder]);
const formData = isJsonObject(rawFormData) ? rawFormData : {};
const detectors = formData as JsonObject;
const [addType, setAddType] = useState<string | undefined>(orderedTypes[0]);
const [addError, setAddError] = useState<string | undefined>();
const [renameDrafts, setRenameDrafts] = useState<Record<string, string>>({});
const [renameErrors, setRenameErrors] = useState<Record<string, string>>({});
const [typeErrors, setTypeErrors] = useState<Record<string, string>>({});
const [openKeys, setOpenKeys] = useState<Set<string>>(
() => new Set(Object.keys(detectors)),
);
useEffect(() => {
if (!orderedTypes.length) {
setAddType(undefined);
return;
}
if (!addType || !orderedTypes.includes(addType)) {
setAddType(orderedTypes[0]);
}
}, [orderedTypes, addType]);
useEffect(() => {
setOpenKeys((prev) => {
const next = new Set<string>();
Object.keys(detectors).forEach((key) => {
if (prev.has(key)) {
next.add(key);
}
});
return next;
});
setRenameDrafts((prev) => {
const next: Record<string, string> = {};
Object.keys(detectors).forEach((key) => {
if (prev[key] !== undefined) {
next[key] = prev[key];
}
});
return next;
});
setRenameErrors((prev) => {
const next: Record<string, string> = {};
Object.keys(detectors).forEach((key) => {
if (prev[key] !== undefined) {
next[key] = prev[key];
}
});
return next;
});
setTypeErrors((prev) => {
const next: Record<string, string> = {};
Object.keys(detectors).forEach((key) => {
if (prev[key] !== undefined) {
next[key] = prev[key];
}
});
return next;
});
}, [detectors]);
const updateDetectors = useCallback(
(nextDetectors: JsonObject, path?: FieldPathList) => {
onChange(nextDetectors as unknown, path ?? fieldPathId.path);
},
[fieldPathId.path, onChange],
);
const getTypeLabel = useCallback(
(type: string) =>
t(`${sectionPrefix}.${type}.label`, {
ns: configNamespace,
defaultValue: humanizeKey(type),
}),
[t, sectionPrefix, configNamespace],
);
const getTypeDescription = useCallback(
(type: string) =>
t(`${sectionPrefix}.${type}.description`, {
ns: configNamespace,
defaultValue: "",
}),
[t, sectionPrefix, configNamespace],
);
const shouldShowRestartForPath = useCallback(
(path: Array<string | number>) =>
requiresRestartForFieldPath(
path,
restartRequired,
defaultRequiresRestart,
),
[defaultRequiresRestart, restartRequired],
);
const renderRestartIcon = (isRequired: boolean) => {
if (!isRequired) {
return null;
}
return <RestartRequiredIndicator className="ml-2" />;
};
const isSingleInstanceType = useCallback(
(type: string) => !multiInstanceSet.has(type),
[multiInstanceSet],
);
const getDetectorDefaults = useCallback(
(type: string) => {
const schema = detectorSchemaByType.get(type);
if (!schema) {
return { type };
}
const base = { type } as Record<string, unknown>;
const withDefaults = applySchemaDefaults(schema, base);
return { ...withDefaults, type } as Record<string, unknown>;
},
[detectorSchemaByType],
);
const resolveDuplicateType = useCallback(
(targetType: string, excludeKey?: string) => {
return Object.entries(detectors).some(([key, value]) => {
if (excludeKey && key === excludeKey) {
return false;
}
return getInstanceType(value) === targetType;
});
},
[detectors],
);
const getExistingType = useCallback(
(excludeKey?: string): string | undefined => {
for (const [key, value] of Object.entries(detectors)) {
if (excludeKey && key === excludeKey) continue;
const type = getInstanceType(value);
if (type) return type;
}
return undefined;
},
[detectors],
);
const handleAdd = useCallback(() => {
if (!addType) {
setAddError(
t("selectItem", {
ns: "common",
defaultValue: "Select {{item}}",
item: t("detectors.type.label", {
ns: configNamespace,
defaultValue: "Type",
}),
}),
);
return;
}
if (isSingleInstanceType(addType) && resolveDuplicateType(addType)) {
setAddError(
t("configForm.detectors.singleType", {
ns: "views/settings",
defaultValue: "Only one {{type}} detector is allowed.",
type: getTypeLabel(addType),
}),
);
return;
}
const existingType = getExistingType();
if (existingType && existingType !== addType) {
const canAddExisting =
multiInstanceSet.has(existingType) ||
!resolveDuplicateType(existingType);
setAddError(
canAddExisting
? t("configMessages.detectors.mixedTypesSuggestion", {
ns: "views/settings",
defaultValue:
"All detectors must use the same type. Remove existing detectors or select {{type}}.",
type: getTypeLabel(existingType),
})
: t("configMessages.detectors.mixedTypes", {
ns: "views/settings",
defaultValue:
"All detectors must use the same type. Remove existing detectors to use a different type.",
}),
);
return;
}
const baseKey = addType;
let nextKey = baseKey;
let index = 2;
while (Object.prototype.hasOwnProperty.call(detectors, nextKey)) {
nextKey = `${baseKey}${index}`;
index += 1;
}
const nextDetectors = {
...detectors,
[nextKey]: getDetectorDefaults(addType),
} as JsonObject;
setAddError(undefined);
setOpenKeys((prev) => {
const next = new Set(prev);
next.add(nextKey);
return next;
});
updateDetectors(nextDetectors);
}, [
addType,
t,
configNamespace,
detectors,
getDetectorDefaults,
getExistingType,
getTypeLabel,
isSingleInstanceType,
multiInstanceSet,
resolveDuplicateType,
updateDetectors,
]);
const handleRemove = useCallback(
(key: string) => {
const { [key]: _, ...rest } = detectors;
updateDetectors(rest as JsonObject);
setOpenKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
},
[detectors, updateDetectors],
);
const commitRename = useCallback(
(key: string, nextKey: string) => {
const trimmed = nextKey.trim();
if (!trimmed) {
setRenameErrors((prev) => ({
...prev,
[key]: t("configForm.detectors.keyRequired", {
ns: "views/settings",
defaultValue: "Detector name is required.",
}),
}));
return;
}
if (trimmed !== key && detectors[trimmed] !== undefined) {
setRenameErrors((prev) => ({
...prev,
[key]: t("configForm.detectors.keyDuplicate", {
ns: "views/settings",
defaultValue: "Detector name already exists.",
}),
}));
return;
}
setRenameErrors((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
setRenameDrafts((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
if (trimmed === key) {
return;
}
const { [key]: value, ...rest } = detectors;
const nextDetectors = { ...rest, [trimmed]: value } as JsonObject;
setOpenKeys((prev) => {
const next = new Set(prev);
if (next.delete(key)) {
next.add(trimmed);
}
return next;
});
updateDetectors(nextDetectors);
},
[detectors, t, updateDetectors],
);
const handleTypeChange = useCallback(
(key: string, nextType: string) => {
const currentType = getInstanceType(detectors[key]);
if (!nextType || nextType === currentType) {
return;
}
if (
isSingleInstanceType(nextType) &&
resolveDuplicateType(nextType, key)
) {
setTypeErrors((prev) => ({
...prev,
[key]: t("configForm.detectors.singleType", {
ns: "views/settings",
defaultValue: "Only one {{type}} detector is allowed.",
type: getTypeLabel(nextType),
}),
}));
return;
}
const existingType = getExistingType(key);
if (existingType && existingType !== nextType) {
const canAddExisting =
multiInstanceSet.has(existingType) ||
!resolveDuplicateType(existingType, key);
setTypeErrors((prev) => ({
...prev,
[key]: canAddExisting
? t("configMessages.detectors.mixedTypesSuggestion", {
ns: "views/settings",
defaultValue:
"All detectors must use the same type. Remove existing detectors or select {{type}}.",
type: getTypeLabel(existingType),
})
: t("configMessages.detectors.mixedTypes", {
ns: "views/settings",
defaultValue:
"All detectors must use the same type. Remove existing detectors to use a different type.",
}),
}));
return;
}
setTypeErrors((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
const nextDetectors = {
...detectors,
[key]: getDetectorDefaults(nextType),
} as JsonObject;
updateDetectors(nextDetectors);
},
[
detectors,
getDetectorDefaults,
getExistingType,
getTypeLabel,
isSingleInstanceType,
multiInstanceSet,
resolveDuplicateType,
t,
updateDetectors,
],
);
const getInstanceUiSchema = useCallback(
(type: string) => {
const baseUiSchema =
(uiSchema?.additionalProperties as UiSchema | undefined) ?? {};
const globalHidden = buildHiddenUiSchema(globalHiddenFields);
const hiddenOverrides = buildHiddenUiSchema(hiddenByType[type] ?? []);
const typeHidden = { type: { "ui:widget": "hidden" } } as UiSchema;
const nestedOverrides = {
"ui:options": {
disableNestedCard: true,
addButtonText: t("configForm.detectors.addCustomKey", {
ns: "views/settings",
defaultValue: "Add custom key",
}),
},
} as UiSchema;
const withGlobalHidden = mergeUiSchema(baseUiSchema, globalHidden);
const withTypeHidden = mergeUiSchema(withGlobalHidden, hiddenOverrides);
const withTypeHiddenAndOptions = mergeUiSchema(
withTypeHidden,
typeHidden,
);
return mergeUiSchema(withTypeHiddenAndOptions, nestedOverrides);
},
[globalHiddenFields, hiddenByType, t, uiSchema?.additionalProperties],
);
const renderInstanceForm = useCallback(
(key: string, value: unknown) => {
const SchemaField = registry.fields.SchemaField;
const type = getInstanceType(value);
const schema = type ? detectorSchemaByType.get(type) : undefined;
if (!SchemaField || !schema || !type) {
return null;
}
const instanceUiSchema = getInstanceUiSchema(type);
const instanceFieldPathId = toFieldPathId(
key,
registry.globalFormOptions,
fieldPathId.path,
);
const instanceErrorSchema = (
errorSchema as Record<string, ErrorSchema> | undefined
)?.[key];
const handleInstanceChange = (
nextValue: unknown,
path: FieldPathList,
errors?: ErrorSchema,
id?: string,
) => {
onChange(nextValue, path, errors, id);
};
return (
<SchemaField
name={key}
schema={schema}
uiSchema={instanceUiSchema}
fieldPathId={instanceFieldPathId}
formData={value}
errorSchema={instanceErrorSchema}
onChange={handleInstanceChange}
onBlur={onBlur}
onFocus={onFocus}
registry={registry}
disabled={disabled}
readonly={readonly}
hideError={hideError}
/>
);
},
[
detectorSchemaByType,
getInstanceUiSchema,
disabled,
errorSchema,
fieldPathId,
hideError,
onChange,
onBlur,
onFocus,
readonly,
registry,
],
);
if (!availableTypes.length) {
return (
<p className="text-sm text-muted-foreground">
{t("configForm.detectors.noSchema", {
ns: "views/settings",
defaultValue: "No detector schemas are available.",
})}
</p>
);
}
const detectorEntries = Object.entries(detectors);
const isDisabled = Boolean(disabled || readonly);
return (
<div className="space-y-4">
{detectorEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("configForm.detectors.none", {
ns: "views/settings",
defaultValue: "No detector instances configured.",
})}
</p>
) : (
<div className="space-y-3">
{detectorEntries.map(([key, value]) => {
const type = getInstanceType(value) ?? "";
const typeLabel = type ? getTypeLabel(type) : key;
const typeDescription = type ? getTypeDescription(type) : "";
const isOpen = openKeys.has(key);
const renameDraft = renameDrafts[key] ?? key;
const detectorPath = [...fieldPathId.path, key];
const detectorTypePath = [...detectorPath, "type"];
const detectorTypeRequiresRestart =
shouldShowRestartForPath(detectorTypePath);
return (
<div key={key} className="rounded-lg border bg-card">
<Collapsible
open={isOpen}
onOpenChange={(open) => {
setOpenKeys((prev) => {
const next = new Set(prev);
if (open) {
next.add(key);
} else {
next.delete(key);
}
return next;
});
}}
>
<div className="flex items-start justify-between gap-4 p-4">
<div className="flex items-start gap-3">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="mt-0.5"
>
{isOpen ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<div>
<div className="flex items-center text-sm font-medium">
{typeLabel}
{renderRestartIcon(detectorTypeRequiresRestart)}
<span className="ml-2 text-xs text-muted-foreground">
{key}
</span>
</div>
{typeDescription && (
<div className="text-xs text-muted-foreground">
{typeDescription}
</div>
)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => handleRemove(key)}
disabled={isDisabled}
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
<CollapsibleContent>
<div className="space-y-4 border-t p-4">
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2">
<Label className="flex items-center">
{t("label.ID", {
ns: "common",
defaultValue: "ID",
})}
</Label>
<Input
value={renameDraft}
disabled={isDisabled}
onChange={(event) => {
setRenameDrafts((prev) => ({
...prev,
[key]: event.target.value,
}));
}}
onBlur={(event) =>
commitRename(key, event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitRename(key, renameDraft);
}
}}
/>
<p className="text-xs text-muted-foreground">
{t("field.internalID", {
ns: "common",
defaultValue:
"The Internal ID Frigate uses in the configuration and database",
})}
</p>
{renameErrors[key] && (
<p className="text-xs text-danger">
{renameErrors[key]}
</p>
)}
</div>
<div className="col-span-3 space-y-2">
<Label className="flex items-center">
{t("detectors.type.label", {
ns: configNamespace,
defaultValue: "Type",
})}
</Label>
<Select
value={type}
onValueChange={(value) =>
handleTypeChange(key, value)
}
disabled={isDisabled}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectItem", {
ns: "common",
defaultValue: "Select {{item}}",
item: t("detectors.type.label", {
ns: configNamespace,
defaultValue: "Type",
}),
})}
/>
</SelectTrigger>
<SelectContent>
{orderedTypes.map((option) => (
<SelectItem key={option} value={option}>
{getTypeLabel(option)}
</SelectItem>
))}
</SelectContent>
</Select>
{typeErrors[key] && (
<p className="text-xs text-danger">
{typeErrors[key]}
</p>
)}
</div>
</div>
<div className={cn(readonly && "opacity-90")}>
{renderInstanceForm(key, value)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
})}
</div>
)}
<div className="flex justify-start pt-5">
<div className="w-full max-w-lg rounded-lg border bg-card p-4">
<div className="text-sm font-medium text-muted-foreground">
{t("configForm.detectors.add", {
ns: "views/settings",
defaultValue: "Add detector",
})}
</div>
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-end">
<div className="flex-1 space-y-2">
<Label>
{t("detectors.type.label", {
ns: configNamespace,
defaultValue: "Type",
})}
</Label>
<Select
value={addType ?? ""}
onValueChange={(value) => {
setAddError(undefined);
setAddType(value);
}}
disabled={isDisabled}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectItem", {
ns: "common",
defaultValue: "Select {{item}}",
item: t("detectors.type.label", {
ns: configNamespace,
defaultValue: "Type",
}),
})}
/>
</SelectTrigger>
<SelectContent>
{orderedTypes.map((type) => (
<SelectItem key={type} value={type}>
{getTypeLabel(type)}
</SelectItem>
))}
</SelectContent>
</Select>
{addError && <p className="text-xs text-danger">{addError}</p>}
</div>
<div>
<Button
type="button"
variant="outline"
onClick={handleAdd}
disabled={isDisabled}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("button.add", {
ns: "common",
defaultValue: "Add",
})}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,266 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DetectionHardware } from "@/types/hardware";
import {
hardwareForDevices,
MAX_DETECTORS,
recommendedDetectorCount,
} from "@/utils/detectionHardware";
type HardwarePickerProps = {
// scopes the unit checkbox ids, since several models can list the same unit
idPrefix: string;
devices: string[];
// device strings already taken by another model, mapped to that model's scene
claimedElsewhere: Record<string, string>;
cameraCount: number;
disabled?: boolean;
onChange: (devices: string[]) => void;
};
export function HardwarePicker({
idPrefix,
devices,
claimedElsewhere,
cameraCount,
disabled,
onChange,
}: HardwarePickerProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { data: hardware, isLoading } =
useSWR<DetectionHardware[]>("hardware/probe");
const selected = useMemo(
() => hardwareForDevices(hardware ?? [], devices),
[hardware, devices],
);
const recommended = useMemo(
() => recommendedDetectorCount(cameraCount),
[cameraCount],
);
// the units a model is assigned to, in the order the probe reports them. A
// shareable unit repeats in `devices` once per inference process, so the
// distinct entries are what is selected.
const selectedUnits = useMemo(() => {
if (!selected) {
return [];
}
return selected.units
.map((unit) => unit.device)
.filter((device) => devices.includes(device));
}, [selected, devices]);
/** Spread `count` detectors round robin over the selected units. */
const buildDevices = useCallback((units: string[], count: number) => {
if (units.length === 0) {
return [];
}
return Array.from(
{ length: Math.max(count, units.length) },
(_, index) => units[index % units.length],
);
}, []);
const handleHardwareChange = useCallback(
(key: string) => {
const entry = hardware?.find((candidate) => candidate.key === key);
if (!entry) {
return;
}
// start with the first unit no other model has taken
const free = entry.units.find((unit) => !claimedElsewhere[unit.device]);
if (!free) {
onChange([]);
return;
}
onChange(
entry.unlimited
? buildDevices([free.device], recommended)
: [free.device],
);
},
[hardware, claimedElsewhere, recommended, buildDevices, onChange],
);
const handleUnitToggle = useCallback(
(device: string, checked: boolean) => {
if (!selected) {
return;
}
const units = selected.units
.map((unit) => unit.device)
.filter((candidate) =>
candidate === device ? checked : selectedUnits.includes(candidate),
);
if (!selected.unlimited) {
onChange(units);
return;
}
// keep the detector count while the set of units changes
onChange(buildDevices(units, devices.length));
},
[selected, selectedUnits, devices.length, buildDevices, onChange],
);
const handleCountChange = useCallback(
(value: string) => {
onChange(buildDevices(selectedUnits, Number(value)));
},
[selectedUnits, buildDevices, onChange],
);
if (isLoading) {
return (
<p className="text-sm text-muted-foreground">
{t("detectionModels.hardware.loading")}
</p>
);
}
// a hand-written config can name hardware this system does not report
const unrecognized = devices.length > 0 && !selected;
return (
<div className="space-y-6">
<div className="space-y-1">
<Label>{t("detectionModels.hardware.label")}</Label>
<Select
value={selected?.key ?? ""}
onValueChange={handleHardwareChange}
disabled={disabled}
>
<SelectTrigger className="max-w-xs">
<SelectValue
placeholder={t("detectionModels.hardware.placeholder")}
/>
</SelectTrigger>
<SelectContent>
{(hardware ?? []).map((entry) => (
<SelectItem key={entry.key} value={entry.key}>
{entry.name}
{entry.count > 1 ? ` (${entry.count})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("detectionModels.hardware.description")}
</p>
</div>
{unrecognized ? (
<p className="text-sm text-danger">
{t("detectionModels.hardware.unrecognized", {
devices: devices.join(", "),
})}
</p>
) : null}
{selected && (selected.units.length > 1 || !selected.unlimited) ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{t("detectionModels.hardware.unitsDescription")}
</p>
{selected.units.map((unit) => {
const claimedBy = claimedElsewhere[unit.device];
return (
<div
key={unit.device}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<Checkbox
id={`${idPrefix}-${unit.device}`}
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={devices.includes(unit.device)}
disabled={disabled || Boolean(claimedBy)}
onCheckedChange={(checked) =>
handleUnitToggle(unit.device, checked === true)
}
/>
<Label
htmlFor={`${idPrefix}-${unit.device}`}
className="cursor-pointer font-normal"
>
{unit.label}
{claimedBy ? (
<span className="ml-2 text-xs text-muted-foreground">
{t("detectionModels.hardware.claimedBy", {
scene: claimedBy,
})}
</span>
) : null}
</Label>
</div>
);
})}
</div>
) : null}
{selected?.unlimited ? (
<div className="space-y-1">
<Label htmlFor={`${idPrefix}-detector-count`}>
{t("detectionModels.hardware.detectorCount")}
</Label>
<Select
value={String(devices.length || 1)}
onValueChange={handleCountChange}
disabled={disabled}
>
<SelectTrigger
id={`${idPrefix}-detector-count`}
className="max-w-xs"
>
{String(devices.length || 1)}
</SelectTrigger>
<SelectContent>
{Array.from({ length: MAX_DETECTORS }, (_, index) => index + 1)
.filter((count) => count >= Math.max(selectedUnits.length, 1))
.map((count) => (
<SelectItem key={count} value={String(count)}>
<div className="flex h-max flex-col justify-between">
<div>{count}</div>
{count === recommended ? (
<div className="text-xs text-muted-foreground">
{t("detectionModels.hardware.countRecommended", {
count: cameraCount,
})}
</div>
) : null}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("detectionModels.hardware.detectorCountDescription")}
</p>
</div>
) : null}
</div>
);
}
export default HardwarePicker;

View File

@ -0,0 +1,175 @@
import { ReactNode, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import useSWR from "swr";
import axios from "axios";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { FrigateConfig } from "@/types/frigateConfig";
export type FrigatePlusModel = {
id: string;
name: string;
baseModel: string;
trainDate: string;
isBaseModel: boolean;
supportedDetectors: string[];
width: number;
height: number;
};
const PLUS_PREFIX = "plus://";
/** The Frigate+ model id a path refers to, if it is a Frigate+ path at all. */
function plusModelId(path: unknown): string | undefined {
return typeof path === "string" && path.startsWith(PLUS_PREFIX)
? path.slice(PLUS_PREFIX.length)
: undefined;
}
type ModelSourcePickerProps = {
path: unknown;
// Frigate+ metadata the backend attaches to a saved model, and the only
// reliable signal that one is active: it resolves `plus://<id>` to a local
// cache path before serving the config back
plus?: { id: string } | null;
// the detector this model runs on, used to filter incompatible Plus models
detector?: string;
disabled?: boolean;
onPathChange: (path: string | undefined) => void;
// the schema-driven fields for a custom model
customFields: ReactNode;
};
export function ModelSourcePicker({
path,
plus,
detector,
disabled,
onPathChange,
customFields,
}: ModelSourcePickerProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const plusEnabled = Boolean(config?.plus?.enabled);
// an unsaved pick still carries the plus:// path, which wins over the
// metadata of whatever model was saved before it
const selectedId = plusModelId(path) ?? plus?.id;
const { data: availableModels, isLoading } = useSWR<
Record<string, FrigatePlusModel>
>(plusEnabled ? "/plus/models" : null, {
fetcher: async (url) => {
const res = await axios.get(url, { withCredentials: true });
return res.data.reduce(
(obj: Record<string, FrigatePlusModel>, model: FrigatePlusModel) => {
obj[model.id] = model;
return obj;
},
{},
);
},
});
const entries = useMemo(
() => Object.entries(availableModels ?? {}),
[availableModels],
);
// the tab cannot be derived from the path alone: switching to Frigate+
// leaves the path untouched until a model is picked
const [tab, setTab] = useState<"plus" | "custom">(
selectedId ? "plus" : "custom",
);
const handleTabChange = (value: string) => {
setTab(value as "plus" | "custom");
// a resolved Frigate+ path is meaningless as a custom path, so drop it
if (value === "custom" && selectedId) {
onPathChange(undefined);
}
};
const isCompatible = (model: FrigatePlusModel) =>
!detector || model.supportedDetectors.includes(detector);
if (!plusEnabled) {
return <div className="space-y-6">{customFields}</div>;
}
const describe = (model: FrigatePlusModel) =>
`${new Date(model.trainDate).toLocaleString()} ${model.baseModel} (${
model.isBaseModel
? t("frigatePlus.modelInfo.plusModelType.baseModel")
: t("frigatePlus.modelInfo.plusModelType.userModel")
}) ${model.name} (${model.width}x${model.height})`;
return (
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className="mb-4">
<TabsTrigger value="plus">{t("detectionModels.tabs.plus")}</TabsTrigger>
<TabsTrigger value="custom">
{t("detectionModels.tabs.custom")}
</TabsTrigger>
</TabsList>
<TabsContent value="plus" className="space-y-1">
<Label>{t("frigatePlus.modelInfo.availableModels")}</Label>
<Select
value={selectedId ?? ""}
onValueChange={(id) => onPathChange(`${PLUS_PREFIX}${id}`)}
disabled={disabled}
>
<SelectTrigger className="w-full max-w-2xl">
{selectedId && availableModels?.[selectedId]
? describe(availableModels[selectedId])
: isLoading
? t("frigatePlus.modelInfo.loadingAvailableModels")
: t("detectionModels.plusModel.noModelSelected")}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{entries.length === 0 ? (
<div className="px-4 py-3 text-center text-sm text-muted-foreground">
{t("frigatePlus.modelInfo.noModelsAvailable")}
</div>
) : (
entries.map(([id, model]) => (
<SelectItem
key={id}
className="cursor-pointer"
value={id}
disabled={!isCompatible(model)}
>
<div>{describe(model)}</div>
<div className="text-xs text-muted-foreground">
{t("frigatePlus.modelInfo.supportedDetectors")}:{" "}
{model.supportedDetectors.join(", ")}
</div>
</SelectItem>
))
)}
</SelectGroup>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
<Trans ns="views/settings">frigatePlus.modelInfo.modelSelect</Trans>
</p>
</TabsContent>
<TabsContent value="custom" className="space-y-6">
{customFields}
</TabsContent>
</Tabs>
);
}
export default ModelSourcePicker;

View File

@ -0,0 +1,460 @@
import type {
ErrorSchema,
FieldProps,
RJSFSchema,
UiSchema,
} from "@rjsf/utils";
import { toFieldPathId } from "@rjsf/utils";
import { cloneDeep } from "lodash";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LuChevronDown,
LuChevronRight,
LuPlus,
LuTrash2,
} from "react-icons/lu";
import { applySchemaDefaults } from "@/lib/config-schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ConfigFormContext } from "@/types/configForm";
import useSWR from "swr";
import { DetectionHardware } from "@/types/hardware";
import { summarizeDevices } from "@/utils/detectionHardware";
import { HardwarePicker } from "./HardwarePicker";
import { ModelSourcePicker } from "./ModelSourcePicker";
type DetectionModel = {
scene?: string;
devices?: string[];
[key: string]: unknown;
};
// scene and devices get dedicated controls; everything else is the model itself
const CUSTOM_MODEL_FIELDS = [
"path",
"labelmap_path",
"width",
"height",
"input_pixel_format",
"input_tensor",
"input_dtype",
"model_type",
];
/** The detector a model runs on, which is the prefix of its device strings. */
const detectorForModel = (model: DetectionModel): string | undefined =>
model.devices?.[0]?.split(":")[0];
const asModelList = (formData: unknown): DetectionModel[] => {
if (!Array.isArray(formData)) {
return [];
}
return formData.filter(
(item): item is DetectionModel => typeof item === "object" && item !== null,
);
};
const getItemSchema = (schema: RJSFSchema): RJSFSchema | undefined => {
const items = schema.items;
if (!items || typeof items !== "object" || Array.isArray(items)) {
return undefined;
}
return items as RJSFSchema;
};
const getItemProperties = (
schema: RJSFSchema | undefined,
): Record<string, RJSFSchema> => {
if (!schema || typeof schema.properties !== "object" || !schema.properties) {
return {};
}
return schema.properties as Record<string, RJSFSchema>;
};
const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => {
const scene = getItemProperties(itemSchema).scene as
| Record<string, unknown>
| undefined;
const values = scene?.enum;
return Array.isArray(values)
? values.filter((v): v is string => typeof v === "string")
: [];
};
export function ModelsField(props: FieldProps) {
const {
schema,
uiSchema,
formData,
onChange,
fieldPathId,
registry,
idSchema,
errorSchema,
disabled,
readonly,
hideError,
onBlur,
onFocus,
} = props;
const { t } = useTranslation(["views/settings", "common"]);
const formContext = registry?.formContext as ConfigFormContext | undefined;
const models = useMemo(() => asModelList(formData), [formData]);
const itemSchema = useMemo(
() => getItemSchema(schema as RJSFSchema),
[schema],
);
const itemProperties = useMemo(
() => getItemProperties(itemSchema),
[itemSchema],
);
const itemUiSchema = useMemo(
() =>
((uiSchema as { items?: UiSchema } | undefined)?.items ?? {}) as UiSchema,
[uiSchema],
);
const sceneOptions = useMemo(() => getSceneOptions(itemSchema), [itemSchema]);
const SchemaField = registry.fields.SchemaField;
const [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({});
// shared with HardwarePicker through the SWR cache, so this is not a second
// request
const { data: hardware } = useSWR<DetectionHardware[]>("hardware/probe");
useEffect(() => {
setOpenByIndex((previous) => {
const next: Record<number, boolean> = {};
for (let index = 0; index < models.length; index += 1) {
next[index] = previous[index] ?? true;
}
return next;
});
}, [models.length]);
const cameras = formContext?.fullConfig?.cameras;
const savedModels = formContext?.fullConfig?.models;
// `plus` is a readonly field stripped from the form data, so read it from the
// full config. Match on scene rather than index, which shifts when a model is
// added or removed.
const savedPlusForScene = useCallback(
(scene: string | undefined) =>
savedModels?.find((saved) => saved.scene === scene)?.plus,
[savedModels],
);
// a model serves the cameras naming its scene, plus every camera that names
// no scene at all when it is the "all" model
const cameraCountForScene = useCallback(
(scene: string | undefined): number => {
if (!cameras) {
return 0;
}
return Object.values(cameras).filter((camera) => {
const cameraScene = camera?.detect?.scene;
return cameraScene ? cameraScene === scene : scene === "all";
}).length;
},
[cameras],
);
const claimedByOtherModels = useCallback(
(index: number): Record<string, string> => {
const claimed: Record<string, string> = {};
models.forEach((model, currentIndex) => {
if (currentIndex === index) {
return;
}
(model.devices ?? []).forEach((device) => {
claimed[device] = model.scene ?? String(currentIndex + 1);
});
});
return claimed;
},
[models],
);
const updateModel = useCallback(
(index: number, partial: Partial<DetectionModel>) => {
const next = cloneDeep(models);
next[index] = { ...next[index], ...partial };
onChange(next, fieldPathId.path);
},
[models, onChange, fieldPathId.path],
);
const handleAddModel = useCallback(() => {
const base = itemSchema
? (applySchemaDefaults(itemSchema) as DetectionModel)
: ({} as DetectionModel);
const taken = new Set(models.map((model) => model.scene));
const scene = sceneOptions.find((option) => !taken.has(option));
onChange([...models, { ...base, scene, devices: [] }], fieldPathId.path);
setOpenByIndex((previous) => ({ ...previous, [models.length]: true }));
}, [models, itemSchema, sceneOptions, onChange, fieldPathId.path]);
const handleRemoveModel = useCallback(
(index: number) => {
onChange(
models.filter((_, currentIndex) => currentIndex !== index),
fieldPathId.path,
);
setOpenByIndex((previous) => {
const next: Record<number, boolean> = {};
Object.entries(previous).forEach(([key, value]) => {
const current = Number(key);
if (Number.isNaN(current) || current === index) {
return;
}
next[current > index ? current - 1 : current] = value;
});
return next;
});
},
[models, onChange, fieldPathId.path],
);
const renderField = useCallback(
(index: number, fieldName: string) => {
const fieldSchema = itemProperties[fieldName];
if (!SchemaField || !fieldSchema) {
return null;
}
const itemFieldPathId = toFieldPathId(
fieldName,
registry.globalFormOptions,
[...fieldPathId.path, index],
);
const itemErrors = (
errorSchema as Record<string, ErrorSchema> | undefined
)?.[index] as Record<string, ErrorSchema> | undefined;
return (
<SchemaField
key={fieldName}
name={fieldName}
schema={fieldSchema}
uiSchema={(itemUiSchema[fieldName] as UiSchema | undefined) ?? {}}
fieldPathId={itemFieldPathId}
formData={(models[index] as Record<string, unknown>)?.[fieldName]}
errorSchema={itemErrors?.[fieldName]}
onChange={(nextValue: unknown) =>
updateModel(index, { [fieldName]: nextValue })
}
onBlur={onBlur}
onFocus={onFocus}
registry={registry}
disabled={disabled}
readonly={readonly}
hideError={hideError}
/>
);
},
[
SchemaField,
itemProperties,
itemUiSchema,
models,
registry,
fieldPathId.path,
errorSchema,
updateModel,
onBlur,
onFocus,
disabled,
readonly,
hideError,
],
);
const baseId = idSchema?.$id ?? "models";
return (
<div className="space-y-4">
{models.map((model, index) => {
const open = openByIndex[index] ?? true;
const takenScenes = new Set(
models
.filter((_, currentIndex) => currentIndex !== index)
.map((other) => other.scene),
);
return (
<Card key={`${baseId}-${index}`} className="w-full">
<Collapsible
open={open}
onOpenChange={(nextOpen) =>
setOpenByIndex((previous) => ({
...previous,
[index]: nextOpen,
}))
}
>
<CardHeader className="p-4">
<div className="flex items-center justify-between gap-4">
<CollapsibleTrigger asChild>
<CardTitle className="flex-1 cursor-pointer text-sm">
<span>
{t(`detectionModels.scenes.${model.scene ?? "all"}`)}
</span>
<span className="mt-1 block text-xs font-normal text-muted-foreground">
{summarizeDevices(
hardware ?? [],
model.devices ?? [],
) ?? t("detectionModels.hardware.none")}
{" • "}
{t("detectionModels.cameras", {
count: cameraCountForScene(model.scene),
})}
</span>
</CardTitle>
</CollapsibleTrigger>
<div className="flex shrink-0 items-center gap-1">
{models.length > 1 ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveModel(index)}
disabled={disabled || readonly}
aria-label={t("button.delete", { ns: "common" })}
>
<LuTrash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("button.delete", { ns: "common" })}
</TooltipContent>
</Tooltip>
) : null}
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t(
open ? "button.collapse" : "button.expand",
{ ns: "common" },
)}
>
{open ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
</div>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="space-y-6 p-4 pt-0">
<div className="space-y-1">
<Label>{t("detectionModels.scene.label")}</Label>
<Select
value={model.scene ?? ""}
onValueChange={(scene) => updateModel(index, { scene })}
disabled={disabled || readonly}
>
<SelectTrigger className="max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sceneOptions.map((scene) => (
<SelectItem
key={scene}
value={scene}
disabled={takenScenes.has(scene)}
>
{t(`detectionModels.scenes.${scene}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("detectionModels.scene.description")}
</p>
</div>
<HardwarePicker
idPrefix={`${baseId}-${index}`}
devices={model.devices ?? []}
claimedElsewhere={claimedByOtherModels(index)}
cameraCount={cameraCountForScene(model.scene)}
disabled={disabled || readonly}
onChange={(devices) => updateModel(index, { devices })}
/>
<ModelSourcePicker
path={model.path}
plus={savedPlusForScene(model.scene)}
detector={detectorForModel(model)}
disabled={disabled || readonly}
onPathChange={(path) => updateModel(index, { path })}
customFields={CUSTOM_MODEL_FIELDS.map((fieldName) =>
renderField(index, fieldName),
)}
/>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
})}
{models.length < sceneOptions.length ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddModel}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("detectionModels.addModel")}
</Button>
) : null}
</div>
);
}
export default ModelsField;

View File

@ -1,5 +1,5 @@
// Custom RJSF Fields
export { LayoutGridField } from "./LayoutGridField";
export { DetectorHardwareField } from "./DetectorHardwareField";
export { ModelsField } from "./ModelsField";
export { ReplaceRulesField } from "./ReplaceRulesField";
export { LiveStreamsField } from "./LiveStreamsField";

View File

@ -49,7 +49,7 @@ import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate";
import { LayoutGridField } from "./fields/LayoutGridField";
import { DetectorHardwareField } from "./fields/DetectorHardwareField";
import { ModelsField } from "./fields/ModelsField";
import { ReplaceRulesField } from "./fields/ReplaceRulesField";
import { CameraInputsField } from "./fields/CameraInputsField";
import { DictAsYamlField } from "./fields/DictAsYamlField";
@ -111,7 +111,7 @@ export const frigateTheme: FrigateTheme = {
},
fields: {
LayoutGridField: LayoutGridField,
DetectorHardwareField: DetectorHardwareField,
ModelsField: ModelsField,
ReplaceRulesField: ReplaceRulesField,
CameraInputsField: CameraInputsField,
DictAsYamlField: DictAsYamlField,

View File

@ -111,11 +111,7 @@ const resolveErrorFieldLabel = ({
? "config/cameras"
: formContext?.i18nNamespace;
const translationPath = buildTranslationPath(
stringSegments,
sectionI18nPrefix,
formContext,
);
const translationPath = buildTranslationPath(stringSegments, formContext);
if (effectiveNamespace && translationPath) {
const translated = resolveConfigTranslation(

View File

@ -168,11 +168,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
(!isArrayItemInAdditionalProp || showArrayItemDescription) &&
!suppressDescription;
const translationPath = buildTranslationPath(
pathSegments,
sectionI18nPrefix,
formContext,
);
const translationPath = buildTranslationPath(pathSegments, formContext);
const fieldPath = fieldPathId.path;
const overrides = formContext?.overrides;
const baselineFormData = formContext?.baselineFormData;

View File

@ -252,11 +252,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
? getTranslatedLabel(filterObjectLabel, isAudioLabels ? "audio" : "object")
: undefined;
if (path) {
translationPath = buildTranslationPath(
path,
sectionI18nPrefix,
formContext,
);
translationPath = buildTranslationPath(path, formContext);
// Also get the last property name for fallback label generation
for (let i = path.length - 1; i >= 0; i -= 1) {
const segment = path[i];

View File

@ -8,62 +8,20 @@
import type { ConfigFormContext } from "@/types/configForm";
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const resolveDetectorType = (
detectorConfig: unknown,
detectorKey?: string,
): string | undefined => {
if (!detectorKey || !isRecord(detectorConfig)) {
return undefined;
}
const entry = detectorConfig[detectorKey];
if (!isRecord(entry)) {
return undefined;
}
const typeValue = entry.type;
return typeof typeValue === "string" && typeValue.length > 0
? typeValue
: undefined;
};
const resolveDetectorTypeFromContext = (
formContext: ConfigFormContext | undefined,
detectorKey?: string,
): string | undefined => {
const formData = formContext?.formData;
if (!detectorKey || !isRecord(formData)) {
return undefined;
}
const detectorConfig = isRecord(formData.detectors)
? formData.detectors
: formData;
return resolveDetectorType(detectorConfig, detectorKey);
};
/**
* Build the i18n translation key path for nested fields using the field path
* provided by RJSF. This avoids ambiguity with underscores in field names and
* normalizes dynamic segments like filter object names or detector names.
* normalizes dynamic segments like filter object names.
*
* @param segments Array of path segments (strings and/or numbers)
* @param sectionI18nPrefix Optional section prefix for specialized sections
* @param formContext Optional form context for resolving detector types
* @param formContext Optional form context for resolving attribute labels
* @returns Normalized translation key path as a dot-separated string
*
* @example
* buildTranslationPath(["filters", "person", "threshold"]) => "filters.threshold"
* buildTranslationPath(["detectors", "ov1", "type"]) => "detectors.openvino.type"
* buildTranslationPath(["ov1", "type"], "detectors") => "openvino.type"
*/
export function buildTranslationPath(
segments: Array<string | number>,
sectionI18nPrefix?: string,
formContext?: ConfigFormContext,
): string {
// Filter out numeric indices to get string segments only
@ -97,46 +55,6 @@ export function buildTranslationPath(
return normalized.join(".");
}
// Handle detectors section - resolve the detector type when available
// Example: detectors.ov1.type -> detectors.openvino.type
const detectorsIndex = stringSegments.indexOf("detectors");
if (detectorsIndex !== -1 && stringSegments.length > detectorsIndex + 2) {
const detectorKey = stringSegments[detectorsIndex + 1];
const detectorType = resolveDetectorTypeFromContext(
formContext,
detectorKey,
);
if (detectorType) {
const normalized = [
...stringSegments.slice(0, detectorsIndex + 1),
detectorType,
...stringSegments.slice(detectorsIndex + 2),
];
return normalized.join(".");
}
const normalized = [
...stringSegments.slice(0, detectorsIndex + 1),
...stringSegments.slice(detectorsIndex + 2),
];
return normalized.join(".");
}
// Handle specialized sections like detectors where the first segment is dynamic
// Example: (sectionI18nPrefix="detectors") "ov1.type" -> "openvino.type"
if (sectionI18nPrefix === "detectors" && stringSegments.length > 1) {
const detectorKey = stringSegments[0];
const detectorType = resolveDetectorTypeFromContext(
formContext,
detectorKey,
);
if (detectorType) {
return [detectorType, ...stringSegments.slice(1)].join(".");
}
return stringSegments.slice(1).join(".");
}
return stringSegments.join(".");
}

View File

@ -736,6 +736,13 @@ export function applySchemaDefaults(
schema: RJSFSchema,
formData: Record<string, unknown> = {},
): Record<string, unknown> {
// An array section (models) carries its defaults on the item schema, not
// here. Spreading an array below would turn it into an object keyed by
// index, so hand it back untouched.
if (Array.isArray(formData)) {
return formData as unknown as Record<string, unknown>;
}
const result = { ...formData };
const schemaObj = schema as Record<string, unknown>;

View File

@ -51,7 +51,6 @@ import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView";
import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView";
import Go2RtcStreamsSettingsView from "@/views/settings/Go2RtcStreamsSettingsView";
import DetectorsAndModelSettingsView from "@/views/settings/DetectorsAndModelSettingsView";
import {
SingleSectionPage,
type SettingsPageProps,
@ -96,14 +95,10 @@ import { mutate } from "swr";
import { RJSFSchema } from "@rjsf/utils";
import {
buildConfigDataForPath,
buildHiddenFieldContext,
flattenOverrides,
getSectionConfig,
parseProfileFromSectionPath,
prepareSectionSavePayload,
PROFILE_ELIGIBLE_SECTIONS,
resolveHiddenFieldEntries,
sanitizeSectionData,
} from "@/utils/configUtil";
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors";
@ -115,7 +110,6 @@ import SaveAllPreviewPopover, {
type SaveAllPreviewItem,
} from "@/components/overlay/detail/SaveAllPreviewPopover";
import { useRestart } from "@/api/ws";
import { getPrimaryModel } from "@/utils/modelUtil";
import {
Tooltip,
TooltipContent,
@ -246,6 +240,7 @@ const SystemEnvironmentVariablesSettingsPage = createSectionPage(
);
const SystemTelemetrySettingsPage = createSectionPage("telemetry", "global");
const SystemBirdseyeSettingsPage = createSectionPage("birdseye", "global");
const SystemDetectionModelsPage = createSectionPage("models", "global");
const NotificationsSettingsPage = createSectionPage("notifications", "global");
const SystemMqttSettingsPage = createSectionPage("mqtt", "global");
@ -408,7 +403,7 @@ const settingsGroups = [
},
{
key: "systemDetectorsAndModel",
component: DetectorsAndModelSettingsView,
component: SystemDetectionModelsPage,
},
{ key: "systemDatabase", component: SystemDatabaseSettingsPage },
{ key: "systemMqtt", component: SystemMqttSettingsPage },
@ -560,8 +555,7 @@ const SYSTEM_SECTION_MAPPING: Record<string, SettingsType> = {
environment_vars: "systemEnvironmentVariables",
telemetry: "systemTelemetry",
birdseye: "systemBirdseye",
detectors: "systemDetectorsAndModel",
model: "systemDetectorsAndModel",
models: "systemDetectorsAndModel",
};
const CAMERA_SECTION_KEYS = new Set<SettingsType>(
@ -877,8 +871,7 @@ export default function Settings() {
// Show save/undo all buttons only when at least one pending change lives
// outside the currently visible page. Map each pending key to its menu key
// (e.g. both `detectors` and `model` collapse to `systemDetectorsAndModel`)
// so a composite page with two pending config-sections still counts as one.
// so a page hosting several config-sections still counts as one.
const showSaveAllButtons = useMemo(() => {
const pendingKeys = Object.keys(pendingDataBySection);
if (pendingKeys.length === 0) return false;
@ -912,111 +905,6 @@ export default function Settings() {
// after `mutate("config")` resolves
const keysToClear: string[] = [];
// `detectors` and `model` are owned by DetectorsAndModelSettingsView
const hasPendingDetectors = "detectors" in pendingDataBySection;
const hasPendingModel = "model" in pendingDataBySection;
if (hasPendingDetectors || hasPendingModel) {
try {
const pendingDetectors = hasPendingDetectors
? pendingDataBySection.detectors
: undefined;
const pendingModel = hasPendingModel
? pendingDataBySection.model
: undefined;
// Hidden-field lists come from the section configs themselves so
// they stay in sync with what the embedded forms strip on render
const detectorHiddenFields = resolveHiddenFieldEntries(
getSectionConfig("detectors", "global").hiddenFields,
buildHiddenFieldContext(config, "global"),
);
const modelHiddenFields = resolveHiddenFieldEntries(
getSectionConfig("model", "global").hiddenFields,
buildHiddenFieldContext(config, "global"),
);
const sanitizedDetectors =
pendingDetectors !== undefined
? sanitizeSectionData(pendingDetectors, detectorHiddenFields)
: undefined;
const sanitizedModel =
pendingModel !== undefined
? sanitizeSectionData(pendingModel, modelHiddenFields)
: undefined;
// Pre-clear conditions: detector keys differ from saved config (rename
// or add/remove), OR the model save flips between Plus and Custom modes
let detectorKeysChanged = false;
if (sanitizedDetectors && typeof sanitizedDetectors === "object") {
const pendingKeySet = Object.keys(
sanitizedDetectors as JsonObject,
).sort();
const savedKeySet = [
...(getPrimaryModel(config)?.devices ?? []),
].sort();
detectorKeysChanged =
JSON.stringify(pendingKeySet) !== JSON.stringify(savedKeySet);
}
let modelTabChanged = false;
if (sanitizedModel && typeof sanitizedModel === "object") {
const newPath = (sanitizedModel as { path?: string }).path;
const oldPath = getPrimaryModel(config)?.path;
const newIsPlus =
typeof newPath === "string" && newPath.startsWith("plus://");
const oldIsPlus =
typeof oldPath === "string" && oldPath.startsWith("plus://");
modelTabChanged = newIsPlus !== oldIsPlus;
}
if (detectorKeysChanged || modelTabChanged) {
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: { detectors: null, model: null },
});
} catch {
// best-effort cleanup; the merge-write below will surface any
// real error.
}
}
const combinedConfigData: Record<string, unknown> = {};
if (sanitizedDetectors !== undefined) {
combinedConfigData.detectors = sanitizedDetectors;
}
if (sanitizedModel !== undefined) {
combinedConfigData.model = sanitizedModel;
}
await axios.put("config/set", {
requires_restart: 0,
config_data: combinedConfigData,
});
if (hasPendingDetectors) {
keysToClear.push("detectors");
savedKeys.push("detectors");
}
if (hasPendingModel) {
keysToClear.push("model");
savedKeys.push("model");
}
if (hasPendingDetectors || hasPendingModel) {
successCount++;
anyNeedsRestart = true;
}
} catch (error) {
// eslint-disable-next-line no-console
console.error(
"Save All error saving detectors/model atomically",
error,
);
if (hasPendingDetectors || hasPendingModel) {
failCount++;
}
}
}
// go2rtc streams are owned by Go2RtcStreamsSettingsView
if ("go2rtc_streams" in pendingDataBySection) {
try {
@ -1067,8 +955,7 @@ export default function Settings() {
}
const pendingKeys = Object.keys(pendingDataBySection).filter(
(key) =>
key !== "detectors" && key !== "model" && key !== "go2rtc_streams",
(key) => key !== "go2rtc_streams",
);
for (const key of pendingKeys) {

View File

@ -66,7 +66,7 @@ export interface CameraConfig {
height: number;
max_disappeared: number;
min_initialized: number;
scene: string | null;
scene: string;
stationary: {
interval: number;
max_frames: {

13
web/src/types/hardware.ts Normal file
View File

@ -0,0 +1,13 @@
export type HardwareUnit = {
device: string;
label: string;
};
export type DetectionHardware = {
key: string;
detector: string;
name: string;
units: HardwareUnit[];
count: number;
unlimited: boolean;
};

View File

@ -220,6 +220,26 @@ export function buildOverrides(
) {
return undefined;
}
// Same-length arrays get compared element by element rather than by
// identity, so an item carrying an explicit null where the base simply
// omits the key does not read as a change. `/api/config` serializes with
// exclude_none, so every nullable field a form materializes would
// otherwise look edited the moment the page opens.
if (Array.isArray(base) && base.length === current.length) {
const baseItems = base;
const defaultItems = Array.isArray(defaults) ? defaults : undefined;
const unchanged = current.every(
(item, index) =>
buildOverrides(item, baseItems[index], defaultItems?.[index]) ===
undefined,
);
if (unchanged) {
return undefined;
}
}
return current;
}

View File

@ -0,0 +1,62 @@
import { DetectionHardware } from "@/types/hardware";
// one detector per this many cameras, so the recommendation grows with the
// install without spawning a process per camera
const CAMERAS_PER_DETECTOR = 8;
export const MAX_DETECTORS = 8;
/** How many detectors to suggest for a model serving this many cameras. */
export function recommendedDetectorCount(cameraCount: number): number {
const scaled = Math.ceil(cameraCount / CAMERAS_PER_DETECTOR);
return Math.min(Math.max(scaled, 1), MAX_DETECTORS);
}
/** The hardware whose units cover every one of these device strings. */
export function hardwareForDevices(
hardware: DetectionHardware[],
devices: string[],
): DetectionHardware | undefined {
if (devices.length === 0) {
return undefined;
}
return hardware.find((entry) => {
const known = new Set(entry.units.map((unit) => unit.device));
return devices.every((device) => known.has(device));
});
}
/**
* A short summary of the hardware a model runs on.
*
* Repeating a device is how extra inference processes are configured, so the
* raw list reads as "openvino:NPU, openvino:NPU". Collapse it to a name and a
* count instead.
*/
export function summarizeDevices(
hardware: DetectionHardware[],
devices: string[],
): string | undefined {
if (devices.length === 0) {
return undefined;
}
const known = hardwareForDevices(hardware, devices);
if (known) {
return devices.length > 1
? `${known.name} (${devices.length})`
: known.name;
}
// hardware this system does not report, so fall back to the raw strings
const counts = new Map<string, number>();
devices.forEach((device) =>
counts.set(device, (counts.get(device) ?? 0) + 1),
);
return [...counts.entries()]
.map(([device, count]) => (count > 1 ? `${device} ×${count}` : device))
.join(", ");
}

View File

@ -1,921 +0,0 @@
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { LuExternalLink, LuFilter } from "react-icons/lu";
import { toast } from "sonner";
import axios from "axios";
import useSWR from "swr";
import { useSWRConfig } from "swr";
import { cn } from "@/lib/utils";
import { useRestart } from "@/api/ws";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import Heading from "@/components/ui/heading";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import type { FrigateConfig } from "@/types/frigateConfig";
import type {
SectionStatus,
SettingsPageProps,
} from "@/views/settings/SingleSectionPage";
import type { ConfigSectionData } from "@/types/configForm";
import {
SettingsGroupCard,
SplitCardRow,
} from "@/components/card/SettingsGroupCard";
import { ConfigSectionTemplate } from "@/components/config-form/sections";
import { ConfigMessageBanner } from "@/components/config-form/ConfigMessageBanner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getPrimaryModel } from "@/utils/modelUtil";
import {
buildHiddenFieldContext,
getSectionConfig,
resolveHiddenFieldEntries,
sanitizeSectionData,
} from "@/utils/configUtil";
type ModelTab = "plus" | "custom";
type PageState = {
detectors: ConfigSectionData;
modelTab: ModelTab;
plusModelId: string | undefined;
customModel: ConfigSectionData;
};
type FrigatePlusModel = {
id: string;
type: string;
name: string;
isBaseModel: boolean;
supportedDetectors: string[];
trainDate: string;
baseModel: string;
width: number;
height: number;
};
const TYPE_MODEL_DEFAULTS: Record<string, ConfigSectionData> = {
cpu: {
path: "/cpu_model.tflite",
labelmap_path: "/labelmap.txt",
width: 320,
height: 320,
input_tensor: "nhwc",
input_pixel_format: "rgb",
input_dtype: "int",
model_type: "ssd",
},
edgetpu: {
path: "/edgetpu_model.tflite",
labelmap_path: "/labelmap.txt",
width: 320,
height: 320,
input_tensor: "nhwc",
input_pixel_format: "rgb",
input_dtype: "int",
model_type: "ssd",
},
openvino: {
path: "/openvino-model/ssdlite_mobilenet_v2.xml",
labelmap_path: "/openvino-model/coco_91cl_bkgr.txt",
width: 300,
height: 300,
input_tensor: "nhwc",
input_pixel_format: "bgr",
input_dtype: "int",
model_type: "ssd",
},
};
const STATUS_BAR_KEY = "detectors_and_model";
const EMPTY_PENDING: Record<string, ConfigSectionData> = {};
const deriveInitialState = (config: FrigateConfig): PageState => {
const primaryModel = getPrimaryModel(config);
const plusModelId = primaryModel?.plus?.id;
const modelPath = primaryModel?.path;
const plusEnabled = Boolean(config.plus?.enabled);
// The reliable signal that a Plus model is currently active is the
// `model.plus.id` metadata
let modelTab: ModelTab;
if (plusModelId) {
modelTab = "plus";
} else if (typeof modelPath === "string" && modelPath.length > 0) {
modelTab = "custom";
} else if (plusEnabled) {
modelTab = "plus";
} else {
modelTab = "custom";
}
// Fallback: if Plus is not enabled, prefer Custom regardless of saved state
if (!plusEnabled && modelTab === "plus") {
modelTab = "custom";
}
const {
plus: _plus,
scene: _scene,
devices: _devices,
...modelWithoutPlus
} = (primaryModel ?? {}) as Record<string, unknown>;
// If a Plus model is active, the resolved `model.path` is auto-derived from
// `plus.id` — drop it so the Custom tab starts clean and doesn't silently
// re-save the same Plus model when the user thinks they switched modes.
if (plusModelId) {
delete modelWithoutPlus.path;
}
return {
detectors: { devices: primaryModel?.devices ?? [] } as ConfigSectionData,
modelTab,
plusModelId: plusModelId ?? undefined,
customModel: modelWithoutPlus as ConfigSectionData,
};
};
export default function DetectorsAndModelSettingsView({
setUnsavedChanges,
pendingDataBySection,
onPendingDataChange,
onSectionStatusChange,
isSavingAll,
onSectionSavingChange,
}: SettingsPageProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config } = useSWR<FrigateConfig>("config");
const { mutate: globalMutate } = useSWRConfig();
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
// track the saved config
const snapshot = useMemo<PageState | null>(
() => (config ? deriveInitialState(config) : null),
[config],
);
const [state, setState] = useState<PageState | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [resetKey, setResetKey] = useState(0);
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const { send: sendRestart } = useRestart();
const childPending = pendingDataBySection ?? EMPTY_PENDING;
const [detectorStatus, setDetectorStatus] = useState<SectionStatus>({
hasChanges: false,
isOverridden: false,
hasValidationErrors: false,
});
const [modelStatus, setModelStatus] = useState<SectionStatus>({
hasChanges: false,
isOverridden: false,
hasValidationErrors: false,
});
const [showBaseModels, setShowBaseModels] = useState(true);
const [showFineTunedModels, setShowFineTunedModels] = useState(true);
const plusEnabled = Boolean(config?.plus?.enabled);
const { data: availableModels = {}, isLoading: isLoadingModels } = useSWR<
Record<string, FrigatePlusModel>
>(plusEnabled ? "/plus/models" : null, {
fallbackData: {},
fetcher: async (url) => {
const res = await axios.get(url, { withCredentials: true });
return res.data.reduce(
(obj: Record<string, FrigatePlusModel>, model: FrigatePlusModel) => {
obj[model.id] = model;
return obj;
},
{},
);
},
});
const filteredModelEntries = useMemo(
() =>
Object.entries(availableModels || {}).filter(([, model]) =>
model.isBaseModel ? showBaseModels : showFineTunedModels,
),
[availableModels, showBaseModels, showFineTunedModels],
);
const isFilterActive = !showBaseModels || !showFineTunedModels;
const detectorHiddenFields = useMemo(
() =>
resolveHiddenFieldEntries(
getSectionConfig("detectors", "global").hiddenFields,
buildHiddenFieldContext(config, "global"),
),
[config],
);
const modelHiddenFields = useMemo(
() =>
resolveHiddenFieldEntries(
getSectionConfig("model", "global").hiddenFields,
buildHiddenFieldContext(config, "global"),
),
[config],
);
const liveDetectors = useMemo(
() => childPending["detectors"] ?? snapshot?.detectors,
[childPending, snapshot],
);
const liveCustomModel = useMemo(
() => childPending["model"] ?? snapshot?.customModel,
[childPending, snapshot],
);
const currentDetectorType = useMemo(() => {
const values = Object.values(liveDetectors ?? {});
if (values.length === 0) return undefined;
const first = values[0] as { type?: string } | undefined;
return first?.type;
}, [liveDetectors]);
// fill in defaults when detector type changes
const prevDetectorTypeRef = useRef<string | undefined>(undefined);
useEffect(() => {
const newType = currentDetectorType;
const prevType = prevDetectorTypeRef.current;
prevDetectorTypeRef.current = newType;
if (prevType === undefined || prevType === newType) return;
if (!newType || !(newType in TYPE_MODEL_DEFAULTS)) return;
const defaults = TYPE_MODEL_DEFAULTS[newType];
onPendingDataChange?.("model", undefined, defaults);
if (newType === "openvino") {
const detectorsCurrent = (childPending.detectors ??
state?.detectors ??
{}) as {
[key: string]: { device?: string };
};
const entries = Object.entries(detectorsCurrent);
if (entries.length > 0) {
const [firstKey, firstValue] = entries[0];
if (!firstValue?.device) {
onPendingDataChange?.("detectors", undefined, {
...detectorsCurrent,
[firstKey]: { ...firstValue, device: "CPU" },
} as ConfigSectionData);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDetectorType]);
const isModelCompatible = useCallback(
(model: FrigatePlusModel) =>
currentDetectorType
? model.supportedDetectors.includes(currentDetectorType)
: true,
[currentDetectorType],
);
const selectedPlusModel = state?.plusModelId
? availableModels?.[state.plusModelId]
: undefined;
const plusMismatch =
state?.modelTab === "plus" &&
selectedPlusModel !== undefined &&
currentDetectorType !== undefined &&
!isModelCompatible(selectedPlusModel);
const plusModelMissing = state?.modelTab === "plus" && !state?.plusModelId;
const handleDetectorStatusChange = useCallback(
(status: SectionStatus) => {
setDetectorStatus(status);
onSectionStatusChange?.("detectors", "global", status);
},
[onSectionStatusChange],
);
// BaseSection drives `modelStatus` only when the Custom tab is mounted
const handleModelStatusChange = useCallback(
(status: SectionStatus) => setModelStatus(status),
[],
);
// report the *combined* model-section status to the parent
useEffect(() => {
if (!state || !snapshot) return;
const tabChanged = state.modelTab !== snapshot.modelTab;
const plusIdChanged =
state.modelTab === "plus" && state.plusModelId !== snapshot.plusModelId;
const pageLevelDirty = tabChanged || plusIdChanged;
onSectionStatusChange?.("model", "global", {
hasChanges: modelStatus.hasChanges || pageLevelDirty,
isOverridden: modelStatus.isOverridden,
overrideSource: modelStatus.overrideSource,
hasValidationErrors: modelStatus.hasValidationErrors,
});
}, [state, snapshot, modelStatus, onSectionStatusChange]);
// Tab toggle and Plus-model selection are page-local UI, but Save All and the
// sidebar dot live on `pendingDataBySection["model"]` and section status from
// the parent. These handlers mirror Plus-tab changes into both so a Plus-only
// edit (no custom-form typing) is still dirty and survives navigation.
const handleModelTabChange = useCallback(
(newTab: ModelTab) => {
setState((prev) => (prev ? { ...prev, modelTab: newTab } : prev));
if (!snapshot) return;
if (newTab === "plus") {
if (state?.plusModelId) {
onPendingDataChange?.("model", undefined, {
path: `plus://${state.plusModelId}`,
} as ConfigSectionData);
} else {
// No Plus model selected — clear any stale pending so the save
// action is correctly disabled until the user picks one.
onPendingDataChange?.("model", undefined, null);
}
} else {
// Switching to Custom: if pending["model"] still holds a plus path
// from a previous Plus selection, swap it for the snapshot's custom
// model so Save All writes the correct payload. Don't overwrite
// genuine custom-form edits the user typed earlier.
const currentPath = (
pendingDataBySection?.["model"] as { path?: string } | undefined
)?.path;
if (
typeof currentPath === "string" &&
currentPath.startsWith("plus://")
) {
onPendingDataChange?.(
"model",
undefined,
snapshot.customModel as ConfigSectionData,
);
}
}
},
[state?.plusModelId, snapshot, pendingDataBySection, onPendingDataChange],
);
const handlePlusModelIdChange = useCallback(
(newId: string) => {
setState((prev) => (prev ? { ...prev, plusModelId: newId } : prev));
onPendingDataChange?.("model", undefined, {
path: `plus://${newId}`,
} as ConfigSectionData);
},
[onPendingDataChange],
);
useEffect(() => {
if (!config || state !== null) return;
const initial = deriveInitialState(config);
// Restore Plus-tab UI state from any prior pending edits the user made
// before navigating away. `pendingDataBySection["model"]` is the source of
// truth for Save All; infer modelTab/plusModelId from it so the UI lines up.
const pendingModel = pendingDataBySection?.["model"] as
| { path?: string }
| undefined;
const pendingPath = pendingModel?.path;
if (typeof pendingPath === "string" && pendingPath.startsWith("plus://")) {
setState({
...initial,
modelTab: "plus",
plusModelId: pendingPath.slice("plus://".length) || undefined,
});
} else if (pendingModel && initial.modelTab === "plus") {
// There's a pending custom-model edit while the saved tab was Plus —
// means the user already switched to Custom before navigating away.
setState({ ...initial, modelTab: "custom" });
} else {
setState(initial);
}
}, [config, state, pendingDataBySection]);
const isDirty = useMemo(() => {
if (!state || !snapshot) return false;
if (state.modelTab !== snapshot.modelTab) return true;
if (state.plusModelId !== snapshot.plusModelId) return true;
if ("detectors" in childPending) return true;
if ("model" in childPending) return true;
return false;
}, [state, snapshot, childPending]);
useEffect(() => {
if (isDirty) {
addMessage(
STATUS_BAR_KEY,
t("detectorsAndModel.unsavedChanges"),
undefined,
STATUS_BAR_KEY,
);
} else {
removeMessage(STATUS_BAR_KEY, STATUS_BAR_KEY);
}
setUnsavedChanges?.(isDirty);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDirty]);
useEffect(() => {
document.title = t("documentTitle.detectorsAndModel");
}, [t]);
const onSave = useCallback(async () => {
if (!state || !snapshot) return;
const tabChanged = state.modelTab !== snapshot.modelTab;
// Strip computed/merged fields that the backend populates in /config
// responses but doesn't accept back on /config/set.
const sanitizedDetectors = sanitizeSectionData(
liveDetectors ?? {},
detectorHiddenFields,
);
const sanitizedCustomModel = sanitizeSectionData(
liveCustomModel ?? {},
modelHiddenFields,
);
const modelPayload =
state.modelTab === "plus"
? { path: `plus://${state.plusModelId}` }
: sanitizedCustomModel;
const detectorKeysChanged =
JSON.stringify(Object.keys(liveDetectors ?? {}).sort()) !==
JSON.stringify(Object.keys(snapshot.detectors).sort());
setIsSaving(true);
onSectionSavingChange?.(true);
let preCleared = false;
try {
// Pre-clear both `detectors` and `model` together when renaming
if (tabChanged || detectorKeysChanged) {
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: { detectors: null, model: null },
});
preCleared = true;
} catch {
// best-effort cleanup
}
}
await axios.put("config/set", {
requires_restart: 0,
config_data: {
detectors: sanitizedDetectors,
model: modelPayload,
},
});
await globalMutate("config");
await globalMutate("config/raw_paths");
// `snapshot` is derived from `config` via useMemo, so the awaited mutate
// above has already refreshed it. Just clear the pending entries — that
// resets isDirty since state should now match snapshot.
onPendingDataChange?.("detectors", undefined, null);
onPendingDataChange?.("model", undefined, null);
setResetKey((k) => k + 1);
addMessage(
"detectors_and_model_restart",
t("detectorsAndModel.restartRequired"),
undefined,
"detectors_and_model_restart",
);
toast.success(t("detectorsAndModel.toast.saveSuccess"), {
position: "top-center",
duration: 10000,
action: (
<Button onClick={() => setRestartDialogOpen(true)}>
{t("restart.button", { ns: "components/dialog" })}
</Button>
),
});
} catch (error) {
const err = error as {
response?: { data?: { message?: string; detail?: string } };
};
const message =
err.response?.data?.message ||
err.response?.data?.detail ||
t("detectorsAndModel.toast.saveError");
toast.error(message, { position: "top-center" });
if (preCleared) {
const restoreModel =
snapshot.modelTab === "plus" && snapshot.plusModelId
? { path: `plus://${snapshot.plusModelId}` }
: sanitizeSectionData(snapshot.customModel, modelHiddenFields);
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: {
detectors: sanitizeSectionData(
snapshot.detectors,
detectorHiddenFields,
),
model: restoreModel,
},
});
} catch {
// best-effort
}
}
// Re-sync the config cache to reflect whatever state the backend
// landed on after the failure (and any restore attempt).
await globalMutate("config");
} finally {
setIsSaving(false);
onSectionSavingChange?.(false);
}
}, [
state,
snapshot,
liveDetectors,
liveCustomModel,
detectorHiddenFields,
modelHiddenFields,
globalMutate,
onSectionSavingChange,
addMessage,
onPendingDataChange,
t,
]);
const onUndo = useCallback(() => {
if (snapshot) {
setState(snapshot);
onPendingDataChange?.("detectors", undefined, null);
onPendingDataChange?.("model", undefined, null);
// Force the embedded forms to re-mount so their internal dirty/baseline
// state is rebuilt from the current config — clearing pending alone
// doesn't reset BaseSection's internal tracking.
setResetKey((k) => k + 1);
}
}, [snapshot, onPendingDataChange]);
if (!config || !state) {
return <ActivityIndicator />;
}
const saveDisabled =
!isDirty ||
isSaving ||
isSavingAll ||
detectorStatus.hasValidationErrors ||
(state.modelTab === "custom" && modelStatus.hasValidationErrors) ||
plusMismatch ||
plusModelMissing;
return (
<div className="flex size-full flex-col md:pr-2">
<div className="mb-1 flex items-center justify-between gap-4 pt-2">
<div className="flex max-w-5xl flex-col">
<Heading as="h4">{t("detectorsAndModel.title")}</Heading>
<div className="my-1 text-sm text-muted-foreground">
{t("detectorsAndModel.description")}
</div>
<div className="flex items-center text-sm text-primary-variant">
<Link
to={getLocaleDocUrl("/configuration/object_detectors")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
{isDirty && (
<Badge
variant="secondary"
className="cursor-default bg-unsaved text-xs text-black hover:bg-unsaved"
>
{t("button.modified", { ns: "common", defaultValue: "Modified" })}
</Badge>
)}
</div>
<div className="w-full max-w-5xl space-y-6 pt-4">
<div className="space-y-6">
<SettingsGroupCard title={t("detectorsAndModel.cardTitles.detector")}>
<ConfigSectionTemplate
key={`detectors-${resetKey}`}
sectionKey="detectors"
level="global"
showOverrideIndicator={false}
showTitle={false}
embedded
pendingDataBySection={childPending}
onPendingDataChange={onPendingDataChange}
onStatusChange={handleDetectorStatusChange}
/>
</SettingsGroupCard>
{plusMismatch && selectedPlusModel && (
<ConfigMessageBanner
messages={[
{
key: "plus-mismatch",
messageKey: "detectorsAndModel.mismatch.warning",
severity: "warning",
condition: () => true,
values: {
model: selectedPlusModel.name,
required: selectedPlusModel.supportedDetectors.join(", "),
},
},
]}
/>
)}
<SettingsGroupCard title={t("detectorsAndModel.cardTitles.model")}>
{plusEnabled ? (
<Tabs
value={state.modelTab}
onValueChange={(value) =>
handleModelTabChange(value as ModelTab)
}
>
<TabsList className="mb-4">
<TabsTrigger value="plus">
{t("detectorsAndModel.tabs.plus")}
</TabsTrigger>
<TabsTrigger value="custom">
{t("detectorsAndModel.tabs.custom")}
</TabsTrigger>
</TabsList>
<TabsContent value="plus">
<SplitCardRow
label={t("frigatePlus.modelInfo.availableModels")}
description={
<Trans ns="views/settings">
frigatePlus.modelInfo.modelSelect
</Trans>
}
content={
<div className="flex w-full items-center gap-2">
<Select
value={state.plusModelId}
onValueChange={handlePlusModelIdChange}
>
<SelectTrigger className="w-full">
{state.plusModelId &&
availableModels?.[state.plusModelId]
? new Date(
availableModels[state.plusModelId].trainDate,
).toLocaleString() +
" " +
availableModels[state.plusModelId].baseModel +
" (" +
(availableModels[state.plusModelId].isBaseModel
? t(
"frigatePlus.modelInfo.plusModelType.baseModel",
)
: t(
"frigatePlus.modelInfo.plusModelType.userModel",
)) +
") " +
availableModels[state.plusModelId].name +
" (" +
availableModels[state.plusModelId].width +
"x" +
availableModels[state.plusModelId].height +
")"
: isLoadingModels
? t(
"frigatePlus.modelInfo.loadingAvailableModels",
)
: t(
"detectorsAndModel.plusModel.noModelSelected",
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{filteredModelEntries.length === 0 ? (
<div className="px-4 py-3 text-center text-sm text-muted-foreground">
{t("frigatePlus.modelInfo.noModelsAvailable")}
</div>
) : (
filteredModelEntries.map(([id, model]) => (
<SelectItem
key={id}
className="cursor-pointer"
value={id}
disabled={!isModelCompatible(model)}
>
{new Date(model.trainDate).toLocaleString()}{" "}
<div>
{model.baseModel} {" ("}
{model.isBaseModel
? t(
"frigatePlus.modelInfo.plusModelType.baseModel",
)
: t(
"frigatePlus.modelInfo.plusModelType.userModel",
)}
{")"}
</div>
<div>
{model.name} (
{model.width + "x" + model.height})
</div>
<div>
{t(
"frigatePlus.modelInfo.supportedDetectors",
)}
: {model.supportedDetectors.join(", ")}
</div>
{!isModelCompatible(model) && (
<div className="text-xs text-danger">
{t(
"detectorsAndModel.plusModel.requiresDetector",
{
detector:
model.supportedDetectors.join(
", ",
),
},
)}
</div>
)}
<div className="text-xs text-muted-foreground">
{id}
</div>
</SelectItem>
))
)}
</SelectGroup>
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="focus:outline-none"
aria-label={t(
"frigatePlus.modelInfo.filter.ariaLabel",
)}
>
<LuFilter
className={cn(
"size-4",
isFilterActive
? "text-selected"
: "text-secondary-foreground",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56">
<div className="space-y-3">
<div className="text-sm text-primary-variant">
{t("frigatePlus.modelInfo.filter.ariaLabel")}
</div>
<div className="flex items-center justify-between">
<Label
htmlFor="filterBaseModels"
className="cursor-pointer text-primary"
>
{t("frigatePlus.modelInfo.filter.baseModels")}
</Label>
<Switch
id="filterBaseModels"
checked={showBaseModels}
onCheckedChange={setShowBaseModels}
/>
</div>
<div className="flex items-center justify-between">
<Label
htmlFor="filterFineTunedModels"
className="cursor-pointer text-primary"
>
{t(
"frigatePlus.modelInfo.filter.fineTunedModels",
)}
</Label>
<Switch
id="filterFineTunedModels"
checked={showFineTunedModels}
onCheckedChange={setShowFineTunedModels}
/>
</div>
</div>
</PopoverContent>
</Popover>
</div>
}
/>
</TabsContent>
<TabsContent value="custom">
<ConfigSectionTemplate
key={`model-${resetKey}`}
sectionKey="model"
level="global"
showOverrideIndicator={false}
showTitle={false}
embedded
pendingDataBySection={childPending}
onPendingDataChange={onPendingDataChange}
onStatusChange={handleModelStatusChange}
/>
</TabsContent>
</Tabs>
) : (
<ConfigSectionTemplate
key={`model-${resetKey}`}
sectionKey="model"
level="global"
showOverrideIndicator={false}
showTitle={false}
embedded
pendingDataBySection={childPending}
onPendingDataChange={onPendingDataChange}
onStatusChange={handleModelStatusChange}
/>
)}
</SettingsGroupCard>
</div>
</div>
<div className="sticky bottom-0 z-50 mt-6 w-full border-t border-secondary bg-background pt-0">
<div
className={cn(
"flex flex-col items-center gap-4 pt-2 md:flex-row",
isDirty ? "justify-between" : "justify-end",
)}
>
{isDirty && (
<span className="text-sm text-unsaved">
{t("unsavedChanges", { ns: "views/settings" })}
</span>
)}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
{isDirty && (
<Button
onClick={onUndo}
variant="outline"
disabled={isSaving}
className="flex min-w-36 flex-1 gap-2"
>
{t("button.undo", { ns: "common" })}
</Button>
)}
<Button
onClick={onSave}
variant="select"
disabled={saveDisabled}
className="flex min-w-36 flex-1 gap-2"
>
{isSaving ? (
<>
<ActivityIndicator className="h-4 w-4" />
{t("button.saving", { ns: "common" })}
</>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</div>
<RestartDialog
isOpen={restartDialogOpen}
onClose={() => setRestartDialogOpen(false)}
onRestart={() => sendRestart("restart")}
/>
</div>
);
}