From da135da0fb128a5c131188b829cc648c60459a2d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 18 Aug 2026 11:37:43 -0600 Subject: [PATCH] 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 --- docs/data/object_detectors_models.yaml | 164 +-- docs/docs/configuration/advanced/system.md | 2 +- docs/docs/configuration/config.md | 8 +- docs/docs/configuration/object_detectors.md | 111 +- docs/docs/guides/getting_started.md | 6 +- docs/docs/integrations/plus.md | 2 +- docs/docs/plus/first_model.md | 2 +- docs/docs/troubleshooting/common_errors.md | 2 +- docs/scripts/lib/nav_map.py | 3 +- docs/static/frigate-api.yaml | 101 ++ frigate/api/app.py | 12 +- frigate/api/defs/tags.py | 1 + frigate/api/fastapi_app.py | 2 + frigate/api/hardware.py | 30 + frigate/config/camera/detect.py | 6 +- frigate/config/config.py | 28 +- frigate/detectors/hardware.py | 368 +++++++ .../test/http_api/test_http_audio_labels.py | 55 + frigate/test/test_config.py | 25 +- frigate/test/test_detector_hardware.py | 247 +++++ frigate/test/test_gpu_stats.py | 18 +- frigate/util/services.py | 4 +- generate_api_auth_spec.py | 2 + web/e2e/fixtures/mock-data/config-schema.json | 2 +- .../fixtures/mock-data/config-snapshot.json | 2 +- web/e2e/fixtures/mock-data/hardware.ts | 39 + web/e2e/helpers/api-mocker.ts | 7 + .../specs/settings/detection-models.spec.ts | 404 ++++++++ .../settings/detectors-and-model.spec.ts | 58 -- web/public/locales/en/common.json | 2 + web/public/locales/en/config/cameras.json | 2 +- web/public/locales/en/config/global.json | 2 +- web/public/locales/en/config/validation.json | 3 + web/public/locales/en/views/settings.json | 81 +- .../config-form/section-configs/detect.ts | 24 + .../config-form/section-configs/detectors.ts | 28 - .../section-configs/{model.ts => models.ts} | 89 +- .../config-form/section-validations/index.ts | 6 + .../config-form/section-validations/models.ts | 31 + .../components/config-form/sectionConfigs.ts | 6 +- .../sections/CameraOverridesBadge.tsx | 3 +- .../sections/section-special-cases.ts | 38 +- .../theme/fields/DetectorHardwareField.tsx | 956 ------------------ .../theme/fields/HardwarePicker.tsx | 266 +++++ .../theme/fields/ModelSourcePicker.tsx | 175 ++++ .../config-form/theme/fields/ModelsField.tsx | 460 +++++++++ .../config-form/theme/fields/index.ts | 2 +- .../config-form/theme/frigateTheme.ts | 4 +- .../theme/templates/ErrorListTemplate.tsx | 6 +- .../theme/templates/FieldTemplate.tsx | 6 +- .../theme/templates/ObjectFieldTemplate.tsx | 6 +- .../config-form/theme/utils/i18n.ts | 86 +- web/src/lib/config-schema/transformer.ts | 7 + web/src/pages/Settings.tsx | 123 +-- web/src/types/frigateConfig.ts | 2 +- web/src/types/hardware.ts | 13 + web/src/utils/configUtil.ts | 20 + web/src/utils/detectionHardware.ts | 62 ++ .../DetectorsAndModelSettingsView.tsx | 921 ----------------- 59 files changed, 2554 insertions(+), 2587 deletions(-) create mode 100644 frigate/api/hardware.py create mode 100644 frigate/detectors/hardware.py create mode 100644 frigate/test/http_api/test_http_audio_labels.py create mode 100644 frigate/test/test_detector_hardware.py create mode 100644 web/e2e/fixtures/mock-data/hardware.ts create mode 100644 web/e2e/specs/settings/detection-models.spec.ts delete mode 100644 web/e2e/specs/settings/detectors-and-model.spec.ts delete mode 100644 web/src/components/config-form/section-configs/detectors.ts rename web/src/components/config-form/section-configs/{model.ts => models.ts} (50%) create mode 100644 web/src/components/config-form/section-validations/models.ts delete mode 100644 web/src/components/config-form/theme/fields/DetectorHardwareField.tsx create mode 100644 web/src/components/config-form/theme/fields/HardwarePicker.tsx create mode 100644 web/src/components/config-form/theme/fields/ModelSourcePicker.tsx create mode 100644 web/src/components/config-form/theme/fields/ModelsField.tsx create mode 100644 web/src/types/hardware.ts create mode 100644 web/src/utils/detectionHardware.ts delete mode 100644 web/src/views/settings/DetectorsAndModelSettingsView.tsx diff --git a/docs/data/object_detectors_models.yaml b/docs/data/object_detectors_models.yaml index 6cb79f55f2..d9592d85f0 100644 --- a/docs/data/object_detectors_models.yaml +++ b/docs/data/object_detectors_models.yaml @@ -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://:/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://:/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) diff --git a/docs/docs/configuration/advanced/system.md b/docs/docs/configuration/advanced/system.md index 7b0068f6a5..d891e0f172 100644 --- a/docs/docs/configuration/advanced/system.md +++ b/docs/docs/configuration/advanced/system.md @@ -177,7 +177,7 @@ Custom models may also require different input tensor formats. The colorspace co -Navigate to and open the **Custom Model** tab to configure the model path, dimensions, and input format. +Navigate to and, on the model you want to change, open the **Custom Model** tab to configure the model path, dimensions, and input format. | Field | Description | | --------------------------------------------- | ------------------------------------ | diff --git a/docs/docs/configuration/config.md b/docs/docs/configuration/config.md index afb960966d..5d9eb7ef6b 100644 --- a/docs/docs/configuration/config.md +++ b/docs/docs/configuration/config.md @@ -154,7 +154,7 @@ Here are some common starter configuration examples. These can be configured thr 1. Navigate to and configure the MQTT connection to your Home Assistant Mosquitto broker 2. Navigate to and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)` -3. Navigate to and add a detector with **Type** `EdgeTPU` and **Device** `usb` +3. Navigate to and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown 4. Navigate to 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 and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30` 6. Navigate to and add your camera with the appropriate RTSP stream URL @@ -232,7 +232,7 @@ cameras: 1. Navigate to and set **Enable MQTT** to off 2. Navigate to and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)` -3. Navigate to and add a detector with **Type** `EdgeTPU` and **Device** `usb` +3. Navigate to and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown 4. Navigate to 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 and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30` 6. Navigate to and add your camera with the appropriate RTSP stream URL @@ -308,8 +308,8 @@ cameras: 1. Navigate to and configure the connection to your MQTT broker 2. Navigate to and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)` -3. Navigate to 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 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 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 and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30` 7. Navigate to and add your camera with the appropriate RTSP stream URL diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index a54d528ade..9a8b143610 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -165,7 +165,7 @@ See [common Edge TPU troubleshooting steps](/troubleshooting/edgetpu) if the Edg -Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. +Navigate to and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown. @@ -184,7 +184,7 @@ models: -Navigate to 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 and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown and check each Coral the model should run on. @@ -206,7 +206,7 @@ _warning: may have [compatibility issues](https://github.com/blakeblackshear/fri -Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add**, then leave the device field empty. +Navigate to and select the **Coral EdgeTPU** entry from the **Hardware** dropdown. @@ -225,7 +225,7 @@ models: -Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `pci`. +Navigate to and select **Coral EdgeTPU (PCIe)** from the **Hardware** dropdown. @@ -244,7 +244,7 @@ models: -Navigate to 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 and select **Coral EdgeTPU (PCIe)** from the **Hardware** dropdown and check each Coral the model should run on. @@ -264,7 +264,7 @@ models: -Navigate to 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 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. @@ -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: - - - -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: - 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. - - - -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: - 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. - - - -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: - 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: diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index 7e93214751..dc67684857 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -204,8 +204,8 @@ You need to refer to **Configure hardware acceleration** above to enable the con -1. Navigate to 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 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: -Navigate to and add a detector with **Type** `EdgeTPU` and **Device** `usb`. +Navigate to and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown. diff --git a/docs/docs/integrations/plus.md b/docs/docs/integrations/plus.md index 54cd32835c..2d3ddb94e9 100644 --- a/docs/docs/integrations/plus.md +++ b/docs/docs/integrations/plus.md @@ -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 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 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: diff --git a/docs/docs/plus/first_model.md b/docs/docs/plus/first_model.md index ec6cb5b681..ac32c500e0 100644 --- a/docs/docs/plus/first_model.md +++ b/docs/docs/plus/first_model.md @@ -30,7 +30,7 @@ Models available in Frigate+ can be used with a special model path. No other inf -Navigate to . 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 . 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. diff --git a/docs/docs/troubleshooting/common_errors.md b/docs/docs/troubleshooting/common_errors.md index 6edb6577f1..5c03487180 100644 --- a/docs/docs/troubleshooting/common_errors.md +++ b/docs/docs/troubleshooting/common_errors.md @@ -133,7 +133,7 @@ The process was killed by the CPU for executing an unsupported instruction. Ther -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). diff --git a/docs/scripts/lib/nav_map.py b/docs/scripts/lib/nav_map.py index 0fddf40e00..0f935ae326 100644 --- a/docs/scripts/lib/nav_map.py +++ b/docs/scripts/lib/nav_map.py @@ -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 diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index a78c711073..658f5e58a0 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -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: diff --git a/frigate/api/app.py b/frigate/api/app.py index 185de6fcbe..7427f4d106 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -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) diff --git a/frigate/api/defs/tags.py b/frigate/api/defs/tags.py index c6f37b67f1..2b46be0ea4 100644 --- a/frigate/api/defs/tags.py +++ b/frigate/api/defs/tags.py @@ -8,6 +8,7 @@ class Tags(Enum): chat = "Chat" events = "Events" export = "Export" + hardware = "Hardware" classification = "Classification" logs = "Logs" media = "Media" diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 387f0473fc..a252d4c7c7 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -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) diff --git a/frigate/api/hardware.py b/frigate/api/hardware.py new file mode 100644 index 0000000000..aba5a88a46 --- /dev/null +++ b/frigate/api/hardware.py @@ -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) diff --git a/frigate/config/camera/detect.py b/frigate/config/camera/detect.py index edd07bacde..ea2c5c9850 100644 --- a/frigate/config/camera/detect.py +++ b/frigate/config/camera/detect.py @@ -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, diff --git a/frigate/config/config.py b/frigate/config/config.py index 9455cd41fa..896f97027e 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -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") diff --git a/frigate/detectors/hardware.py b/frigate/detectors/hardware.py new file mode 100644 index 0000000000..756fbd430e --- /dev/null +++ b/frigate/detectors/hardware.py @@ -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() diff --git a/frigate/test/http_api/test_http_audio_labels.py b/frigate/test/http_api/test_http_audio_labels.py new file mode 100644 index 0000000000..76cef9d388 --- /dev/null +++ b/frigate/test/http_api/test_http_audio_labels.py @@ -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) diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index 1a5978dfeb..a4ca0c5510 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -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 = { diff --git a/frigate/test/test_detector_hardware.py b/frigate/test/test_detector_hardware.py new file mode 100644 index 0000000000..f7b0d8c78b --- /dev/null +++ b/frigate/test/test_detector_hardware.py @@ -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) diff --git a/frigate/test/test_gpu_stats.py b/frigate/test/test_gpu_stats.py index f6cf196880..d2124ad879 100644 --- a/frigate/test/test_gpu_stats.py +++ b/frigate/test/test_gpu_stats.py @@ -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 ): diff --git a/frigate/util/services.py b/frigate/util/services.py index 2c13b00b98..aa5427eb5a 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -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() diff --git a/generate_api_auth_spec.py b/generate_api_auth_spec.py index b1f11ebfe7..43b4ababae 100644 --- a/generate_api_auth_spec.py +++ b/generate_api_auth_spec.py @@ -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, diff --git a/web/e2e/fixtures/mock-data/config-schema.json b/web/e2e/fixtures/mock-data/config-schema.json index 32b5a76890..6eca33088f 100644 --- a/web/e2e/fixtures/mock-data/config-schema.json +++ b/web/e2e/fixtures/mock-data/config-schema.json @@ -1 +1 @@ -{"$defs": {"AlertsConfig": {"additionalProperties": false, "description": "Configure alerts", "properties": {"enabled": {"default": true, "description": "Enable or disable alert generation for all cameras; can be overridden per-camera.", "title": "Enable alerts", "type": "boolean"}, "labels": {"default": ["person", "car"], "description": "List of object labels that qualify as alerts (for example: car, person).", "items": {"type": "string"}, "title": "Alert labels", "type": "array"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that an object must enter to be considered an alert; leave empty to allow any zone.", "title": "Required zones"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether alerts were originally enabled in the static configuration.", "title": "Original alerts state"}, "cutoff_time": {"default": 40, "description": "Seconds to wait after no alert-causing activity before cutting off an alert.", "title": "Alerts cutoff time", "type": "integer"}}, "title": "AlertsConfig", "type": "object"}, "AudioConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable audio event detection for all cameras; can be overridden per-camera.", "title": "Enable audio detection", "type": "boolean"}, "max_not_heard": {"default": 30, "description": "Amount of seconds without the configured audio type before the audio event is ended.", "title": "End timeout", "type": "integer"}, "min_volume": {"default": 500, "description": "Minimum RMS volume threshold required to run audio detection; lower values increase sensitivity (e.g., 200 high, 500 medium, 1000 low).", "title": "Minimum volume", "type": "integer"}, "listen": {"default": ["bark", "fire_alarm", "speech", "yell"], "description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell).", "items": {"type": "string"}, "title": "Listen types", "type": "array"}, "filters": {"anyOf": [{"additionalProperties": {"$ref": "#/$defs/AudioFilterConfig"}, "type": "object"}, {"type": "null"}], "default": null, "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.", "title": "Audio filters"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether audio detection was originally enabled in the static config file.", "title": "Original audio state"}, "num_threads": {"default": 2, "description": "Number of threads to use for audio detection processing.", "minimum": 1, "title": "Detection threads", "type": "integer"}}, "title": "AudioConfig", "type": "object"}, "AudioFilterConfig": {"additionalProperties": false, "properties": {"threshold": {"default": 0.8, "description": "Minimum confidence threshold for the audio event to be counted.", "exclusiveMaximum": 1.0, "minimum": 0.5, "title": "Minimum audio confidence", "type": "number"}}, "title": "AudioFilterConfig", "type": "object"}, "AudioTranscriptionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable automatic audio transcription for all cameras; can be overridden per-camera.", "title": "Enable audio transcription", "type": "boolean"}, "language": {"default": "en", "description": "Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.", "title": "Transcription language", "type": "string"}, "device": {"$ref": "#/$defs/EnrichmentsDeviceEnum", "default": "CPU", "description": "Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription.", "title": "Transcription device"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size to use for offline audio event transcription.", "title": "Model size"}, "live_enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Enable streaming live transcription for audio as it is received.", "title": "Live transcription"}}, "title": "AudioTranscriptionConfig", "type": "object"}, "AuthConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable native authentication for the Frigate UI.", "title": "Enable authentication", "type": "boolean"}, "reset_admin_password": {"default": false, "description": "If true, reset the admin user's password on startup and print the new password in logs.", "title": "Reset admin password", "type": "boolean"}, "cookie_name": {"default": "frigate_token", "description": "Name of the cookie used to store the JWT token for native authentication.", "pattern": "^[a-z_]+$", "title": "JWT cookie name", "type": "string"}, "cookie_secure": {"default": false, "description": "Set the secure flag on the auth cookie; should be true when using TLS.", "title": "Secure cookie flag", "type": "boolean"}, "session_length": {"default": 86400, "description": "Session duration in seconds for JWT-based sessions.", "minimum": 60, "title": "Session length", "type": "integer"}, "refresh_time": {"default": 1800, "description": "When a session is within this many seconds of expiring, refresh it back to full length.", "minimum": 30, "title": "Session refresh window", "type": "integer"}, "failed_login_rate_limit": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Rate limiting rules for failed login attempts to reduce brute-force attacks.", "title": "Failed login limits"}, "trusted_proxies": {"default": [], "description": "List of trusted proxy IPs used when determining client IP for rate limiting.", "items": {"type": "string"}, "title": "Trusted proxies", "type": "array"}, "hash_iterations": {"default": 600000, "description": "Number of PBKDF2-SHA256 iterations to use when hashing user passwords.", "title": "Hash iterations", "type": "integer"}, "roles": {"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "description": "Map roles to camera lists. An empty list grants access to all cameras for the role.", "title": "Role mappings", "type": "object"}, "admin_first_time_login": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "When true the UI may show a help link on the login page informing users how to sign in after an admin password reset. ", "title": "First-time admin flag"}}, "title": "AuthConfig", "type": "object"}, "BirdClassificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable bird classification.", "title": "Bird classification", "type": "boolean"}, "threshold": {"default": 0.9, "description": "Minimum classification score required to accept a bird classification.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Minimum score", "type": "number"}}, "title": "BirdClassificationConfig", "type": "object"}, "BirdseyeCameraConfig": {"properties": {"enabled": {"default": true, "description": "Enable or disable the Birdseye view feature.", "title": "Enable Birdseye", "type": "boolean"}, "modes": {"description": "Activity types that include cameras in Birdseye.", "items": {"$ref": "#/$defs/BirdseyeModeEnum"}, "title": "Activity types", "type": "array"}, "order": {"default": 0, "description": "Numeric position controlling the camera's ordering in the Birdseye layout.", "title": "Position", "type": "integer"}}, "title": "BirdseyeCameraConfig", "type": "object"}, "BirdseyeConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable the Birdseye view feature.", "title": "Enable Birdseye", "type": "boolean"}, "modes": {"description": "Activity types that include cameras in Birdseye.", "items": {"$ref": "#/$defs/BirdseyeModeEnum"}, "title": "Activity types", "type": "array"}, "restream": {"default": false, "description": "Re-stream the Birdseye output as an RTSP feed; enabling this will keep Birdseye running continuously.", "title": "Restream RTSP", "type": "boolean"}, "width": {"default": 1280, "description": "Output width (pixels) of the composed Birdseye frame.", "title": "Width", "type": "integer"}, "height": {"default": 720, "description": "Output height (pixels) of the composed Birdseye frame.", "title": "Height", "type": "integer"}, "quality": {"default": 8, "description": "Encoding quality for the Birdseye mpeg1 feed (1 highest quality, 31 lowest).", "maximum": 31, "minimum": 1, "title": "Encoding quality", "type": "integer"}, "inactivity_threshold": {"default": 30, "description": "Seconds of inactivity after which a camera will stop being shown in Birdseye.", "exclusiveMinimum": 0, "title": "Inactivity threshold", "type": "integer"}, "layout": {"$ref": "#/$defs/BirdseyeLayoutConfig", "description": "Layout options for the Birdseye composition.", "title": "Layout"}, "idle_heartbeat_fps": {"default": 0.0, "description": "Frames-per-second to resend the last composed Birdseye frame when idle; set to 0 to disable.", "maximum": 10.0, "minimum": 0.0, "title": "Idle heartbeat FPS", "type": "number"}}, "title": "BirdseyeConfig", "type": "object"}, "BirdseyeLayoutConfig": {"additionalProperties": false, "properties": {"scaling_factor": {"default": 2.0, "description": "Scaling factor used by the layout calculator (range 1.0 to 5.0).", "maximum": 5.0, "minimum": 1.0, "title": "Scaling factor", "type": "number"}, "max_cameras": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Maximum number of cameras to display at once in Birdseye; shows the most recent cameras.", "title": "Max cameras"}}, "title": "BirdseyeLayoutConfig", "type": "object"}, "BirdseyeModeEnum": {"enum": ["continuous", "motion", "all_objects", "alerts", "detections"], "title": "BirdseyeModeEnum", "type": "string"}, "CameraAudioTranscriptionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable manually triggered audio event transcription.", "title": "Enable transcription", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Original transcription state"}, "live_enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Enable streaming live transcription for audio as it is received.", "title": "Live transcription"}}, "title": "CameraAudioTranscriptionConfig", "type": "object"}, "CameraConfig": {"additionalProperties": false, "properties": {"name": {"anyOf": [{"pattern": "^[a-zA-Z0-9_-]+$", "type": "string"}, {"type": "null"}], "default": null, "description": "Camera name is required", "title": "Camera name"}, "friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Camera friendly name used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enabled", "title": "Enabled", "type": "boolean"}, "audio": {"$ref": "#/$defs/AudioConfig", "description": "Settings for audio-based event detection for this camera.", "title": "Audio detection"}, "audio_transcription": {"$ref": "#/$defs/CameraAudioTranscriptionConfig", "description": "Settings for live and speech audio transcription used for events and live captions.", "title": "Audio transcription"}, "birdseye": {"$ref": "#/$defs/BirdseyeCameraConfig", "description": "Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", "title": "Birdseye"}, "detect": {"$ref": "#/$defs/DetectConfig", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "title": "Object Detection"}, "face_recognition": {"$ref": "#/$defs/CameraFaceRecognitionConfig", "description": "Settings for face detection and recognition for this camera.", "title": "Face recognition"}, "ffmpeg": {"$ref": "#/$defs/CameraFfmpegConfig", "description": "Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.", "title": "Streams (FFmpeg)"}, "live": {"$ref": "#/$defs/CameraLiveConfig", "description": "Settings used by the Web UI to control live stream selection, resolution and quality.", "title": "Live playback"}, "lpr": {"$ref": "#/$defs/CameraLicensePlateRecognitionConfig", "description": "License plate recognition settings including detection thresholds, formatting, and known plates.", "title": "License Plate Recognition"}, "motion": {"$ref": "#/$defs/MotionConfig", "default": null, "description": "Default motion detection settings for this camera.", "title": "Motion detection"}, "objects": {"$ref": "#/$defs/ObjectConfig", "description": "Object tracking defaults including which labels to track and per-object filters.", "title": "Objects"}, "record": {"$ref": "#/$defs/RecordConfig", "description": "Recording and retention settings for this camera.", "title": "Recording"}, "review": {"$ref": "#/$defs/ReviewConfig", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.", "title": "Review"}, "semantic_search": {"$ref": "#/$defs/CameraSemanticSearchConfig", "description": "Settings for semantic search which builds and queries object embeddings to find similar items.", "title": "Semantic Search"}, "snapshots": {"$ref": "#/$defs/SnapshotsConfig", "description": "Settings for API-generated snapshots of tracked objects for this camera.", "title": "Snapshots"}, "timestamp_style": {"$ref": "#/$defs/TimestampStyleConfig", "description": "Styling options for timestamps applied to snapshots and Debug view.", "title": "Timestamp style"}, "best_image_timeout": {"default": 60, "description": "How long to wait for the image with the highest confidence score.", "title": "Best image timeout", "type": "integer"}, "mqtt": {"$ref": "#/$defs/CameraMqttConfig", "description": "MQTT image publishing settings.", "title": "MQTT"}, "notifications": {"$ref": "#/$defs/NotificationConfig", "description": "Settings to enable and control notifications for this camera.", "title": "Notifications"}, "onvif": {"$ref": "#/$defs/OnvifConfig", "description": "ONVIF connection and PTZ autotracking settings for this camera.", "title": "ONVIF"}, "type": {"$ref": "#/$defs/CameraTypeEnum", "default": "generic", "description": "Camera Type", "title": "Camera type"}, "ui": {"$ref": "#/$defs/CameraUiConfig", "description": "Display ordering and visibility for this camera in the UI. Ordering affects the default dashboard. For more granular control, use camera groups.", "title": "Camera UI"}, "webui_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "URL to visit the camera directly from system page", "title": "Camera URL"}, "profiles": {"additionalProperties": {"$ref": "#/$defs/CameraProfileConfig"}, "description": "Named config profiles with partial overrides that can be activated at runtime.", "title": "Profiles", "type": "object"}, "zones": {"additionalProperties": {"$ref": "#/$defs/ZoneConfig"}, "description": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.", "title": "Zones", "type": "object"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Keep track of original state of camera.", "title": "Original camera state"}}, "required": ["ffmpeg"], "title": "CameraConfig", "type": "object"}, "CameraFaceRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable face recognition.", "title": "Enable face recognition", "type": "boolean"}, "min_area": {"default": 750, "description": "Minimum area (pixels) of a detected face box required to attempt recognition.", "title": "Minimum face area", "type": "integer"}}, "title": "CameraFaceRecognitionConfig", "type": "object"}, "CameraFfmpegConfig": {"additionalProperties": false, "properties": {"path": {"default": "default", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\").", "title": "FFmpeg path", "type": "string"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "description": "Global arguments passed to FFmpeg processes.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-rtsp-generic", "description": "Input arguments applied to FFmpeg input streams.", "title": "Input arguments"}, "output_args": {"$ref": "#/$defs/FfmpegOutputArgsConfig", "description": "Default output arguments used for different FFmpeg roles such as detect and record.", "title": "Output arguments"}, "retry_interval": {"default": 10.0, "description": "Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", "exclusiveMinimum": 0.0, "title": "FFmpeg retry time", "type": "number"}, "apple_compatibility": {"default": false, "description": "Enable HEVC tagging for better Apple player compatibility when recording H.265.", "title": "Apple compatibility", "type": "boolean"}, "gpu": {"default": 0, "description": "Default GPU index used for hardware acceleration if available.", "title": "GPU index", "type": "integer"}, "inputs": {"description": "List of input stream definitions (paths and roles) for this camera.", "items": {"$ref": "#/$defs/CameraInput"}, "title": "Camera inputs", "type": "array"}}, "required": ["inputs"], "title": "CameraFfmpegConfig", "type": "object"}, "CameraGroupConfig": {"additionalProperties": false, "properties": {"cameras": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Array of camera names included in this group.", "title": "Camera list"}, "icon": {"default": "generic", "description": "Icon used to represent the camera group in the UI.", "title": "Group icon", "type": "string"}, "order": {"default": 0, "description": "Numeric order used to sort camera groups in the UI; larger numbers appear later.", "title": "Sort order", "type": "integer"}}, "title": "CameraGroupConfig", "type": "object"}, "CameraInput": {"additionalProperties": false, "properties": {"path": {"description": "Camera input stream URL or path.", "title": "Input path", "type": "string"}, "roles": {"description": "Roles for this input stream.", "items": {"$ref": "#/$defs/CameraRoleEnum"}, "title": "Input roles", "type": "array"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "FFmpeg global arguments for this input stream.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Hardware acceleration arguments for this input stream.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Input arguments specific to this stream.", "title": "Input arguments"}}, "required": ["path", "roles"], "title": "CameraInput", "type": "object"}, "CameraLicensePlateRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable LPR on this camera.", "title": "Enable LPR", "type": "boolean"}, "expire_time": {"default": 3, "description": "Time in seconds after which an unseen plate is expired from the tracker (for dedicated LPR cameras only).", "exclusiveMinimum": 0, "title": "Expire seconds", "type": "integer"}, "min_area": {"default": 1000, "description": "Minimum plate area (pixels) required to attempt recognition.", "title": "Minimum plate area", "type": "integer"}, "enhancement": {"default": 0, "description": "Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", "maximum": 10, "minimum": 0, "title": "Enhancement level", "type": "integer"}}, "title": "CameraLicensePlateRecognitionConfig", "type": "object"}, "CameraLiveConfig": {"additionalProperties": false, "properties": {"streams": {"additionalProperties": {"type": "string"}, "description": "Mapping of configured stream names to restream/go2rtc names used for live playback.", "title": "Live stream names", "type": "object"}, "height": {"default": 720, "description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height.", "title": "Live height", "type": "integer"}, "quality": {"default": 8, "description": "Encoding quality for the jsmpeg stream (1 highest, 31 lowest).", "maximum": 31, "minimum": 1, "title": "Live quality", "type": "integer"}}, "title": "CameraLiveConfig", "type": "object"}, "CameraMqttConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable publishing image snapshots for objects to MQTT topics for this camera.", "title": "Send image", "type": "boolean"}, "timestamp": {"default": true, "description": "Overlay a timestamp on images published to MQTT.", "title": "Add timestamp", "type": "boolean"}, "bounding_box": {"default": true, "description": "Draw bounding boxes on images published over MQTT.", "title": "Add bounding box", "type": "boolean"}, "crop": {"default": true, "description": "Crop images published to MQTT to the detected object's bounding box.", "title": "Crop image", "type": "boolean"}, "height": {"default": 270, "description": "Height (pixels) to resize images published over MQTT.", "title": "Image height", "type": "integer"}, "required_zones": {"description": "Zones that an object must enter for an MQTT image to be published.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "quality": {"default": 70, "description": "JPEG quality for images published to MQTT (0-100).", "maximum": 100, "minimum": 0, "title": "JPEG quality", "type": "integer"}}, "title": "CameraMqttConfig", "type": "object"}, "CameraProfileConfig": {"additionalProperties": false, "description": "A named profile containing partial camera config overrides.\n\nSections set to None inherit from the camera's base config.\nSections that are defined get Pydantic-validated, then only\nexplicitly-set fields are used as overrides via exclude_unset.", "properties": {"enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Enabled"}, "audio": {"anyOf": [{"$ref": "#/$defs/AudioConfig"}, {"type": "null"}], "default": null}, "birdseye": {"anyOf": [{"$ref": "#/$defs/BirdseyeCameraConfig"}, {"type": "null"}], "default": null}, "detect": {"anyOf": [{"$ref": "#/$defs/DetectConfig"}, {"type": "null"}], "default": null}, "face_recognition": {"anyOf": [{"$ref": "#/$defs/CameraFaceRecognitionConfig"}, {"type": "null"}], "default": null}, "lpr": {"anyOf": [{"$ref": "#/$defs/CameraLicensePlateRecognitionConfig"}, {"type": "null"}], "default": null}, "motion": {"anyOf": [{"$ref": "#/$defs/MotionConfig"}, {"type": "null"}], "default": null}, "notifications": {"anyOf": [{"$ref": "#/$defs/NotificationConfig"}, {"type": "null"}], "default": null}, "objects": {"anyOf": [{"$ref": "#/$defs/ObjectConfig"}, {"type": "null"}], "default": null}, "record": {"anyOf": [{"$ref": "#/$defs/RecordConfig"}, {"type": "null"}], "default": null}, "review": {"anyOf": [{"$ref": "#/$defs/ReviewConfig"}, {"type": "null"}], "default": null}, "snapshots": {"anyOf": [{"$ref": "#/$defs/SnapshotsConfig"}, {"type": "null"}], "default": null}, "zones": {"anyOf": [{"additionalProperties": {"$ref": "#/$defs/ZoneConfig"}, "type": "object"}, {"type": "null"}], "default": null, "title": "Zones"}}, "title": "CameraProfileConfig", "type": "object"}, "CameraRoleEnum": {"enum": ["audio", "record", "record_sub", "detect"], "title": "CameraRoleEnum", "type": "string"}, "CameraSemanticSearchConfig": {"additionalProperties": false, "properties": {"triggers": {"additionalProperties": {"$ref": "#/$defs/TriggerConfig"}, "default": {}, "description": "Actions and matching criteria for camera-specific semantic search triggers.", "title": "Triggers", "type": "object"}}, "title": "CameraSemanticSearchConfig", "type": "object"}, "CameraTypeEnum": {"enum": ["generic", "lpr"], "title": "CameraTypeEnum", "type": "string"}, "CameraUiConfig": {"additionalProperties": false, "properties": {"order": {"default": 0, "description": "Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later.", "title": "UI order", "type": "integer"}, "dashboard": {"default": true, "description": "Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings.", "title": "Show on Live dashboard", "type": "boolean"}, "review": {"default": true, "description": "Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view).", "title": "Show in review", "type": "boolean"}}, "title": "CameraUiConfig", "type": "object"}, "ChaptersEnum": {"enum": ["none", "recording_segments", "review_items"], "title": "ChaptersEnum", "type": "string"}, "ClassificationConfig": {"additionalProperties": false, "properties": {"bird": {"$ref": "#/$defs/BirdClassificationConfig", "description": "Settings specific to bird classification models.", "title": "Bird classification config"}, "custom": {"additionalProperties": {"$ref": "#/$defs/CustomClassificationConfig"}, "default": {}, "description": "Configuration for custom classification models used for objects or state detection.", "title": "Custom Classification Models", "type": "object"}}, "title": "ClassificationConfig", "type": "object"}, "ColorConfig": {"additionalProperties": false, "properties": {"red": {"default": 255, "description": "Red component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Red", "type": "integer"}, "green": {"default": 255, "description": "Green component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Green", "type": "integer"}, "blue": {"default": 255, "description": "Blue component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Blue", "type": "integer"}}, "title": "ColorConfig", "type": "object"}, "CustomClassificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable the custom classification model.", "title": "Enable model", "type": "boolean"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Identifier for the custom classification model to use.", "title": "Model name"}, "threshold": {"default": 0.8, "description": "Score threshold used to change the classification state.", "title": "Score threshold", "type": "number"}, "save_attempts": {"anyOf": [{"minimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "How many classification attempts to save for recent classifications UI.", "title": "Save attempts"}, "object_config": {"anyOf": [{"$ref": "#/$defs/CustomClassificationObjectConfig"}, {"type": "null"}], "default": null}, "state_config": {"anyOf": [{"$ref": "#/$defs/CustomClassificationStateConfig"}, {"type": "null"}], "default": null}}, "title": "CustomClassificationConfig", "type": "object"}, "CustomClassificationObjectConfig": {"additionalProperties": false, "properties": {"objects": {"description": "List of object types to run object classification on.", "items": {"type": "string"}, "title": "Classify objects", "type": "array"}, "classification_type": {"$ref": "#/$defs/ObjectClassificationType", "default": "sub_label", "description": "Classification type applied: 'sub_label' (adds sub_label) or other supported types.", "title": "Classification type"}}, "title": "CustomClassificationObjectConfig", "type": "object"}, "CustomClassificationStateCameraConfig": {"additionalProperties": false, "properties": {"crop": {"description": "Crop coordinates to use for running classification on this camera.", "items": {"type": "number"}, "title": "Classification crop", "type": "array"}}, "required": ["crop"], "title": "CustomClassificationStateCameraConfig", "type": "object"}, "CustomClassificationStateConfig": {"additionalProperties": false, "properties": {"cameras": {"additionalProperties": {"$ref": "#/$defs/CustomClassificationStateCameraConfig"}, "description": "Per-camera crop and settings for running state classification.", "title": "Classification cameras", "type": "object"}, "motion": {"default": false, "description": "If true, run classification when motion is detected within the specified crop.", "title": "Run on motion", "type": "boolean"}, "interval": {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "Interval (seconds) between periodic classification runs for state classification.", "title": "Classification interval"}}, "required": ["cameras"], "title": "CustomClassificationStateConfig", "type": "object"}, "DatabaseConfig": {"additionalProperties": false, "properties": {"path": {"default": "/config/frigate.db", "description": "Filesystem path where the Frigate SQLite database file will be stored.", "title": "Database path", "type": "string"}}, "title": "DatabaseConfig", "type": "object"}, "DetectConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable object detection for all cameras; can be overridden per-camera.", "title": "Enable object detection", "type": "boolean"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Height (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", "title": "Detect height"}, "width": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", "title": "Detect width"}, "scene": {"anyOf": [{"$ref": "#/$defs/SceneEnum"}, {"type": "null"}], "default": null, "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'.", "title": "Detect scene"}, "fps": {"default": 5, "description": "Desired frames per second to run detection on; lower values reduce CPU usage (recommended value is 5, only set higher - at most 10 - if tracking extremely fast moving objects).", "title": "Detect FPS", "type": "integer"}, "min_initialized": {"anyOf": [{"minimum": 2, "type": "integer"}, {"type": "null"}], "default": null, "description": "Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.", "title": "Minimum initialization frames"}, "max_disappeared": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Number of frames without a detection before a tracked object is considered gone.", "title": "Maximum disappeared frames"}, "stationary": {"$ref": "#/$defs/StationaryConfig", "description": "Settings to detect and manage objects that remain stationary for a period of time.", "title": "Stationary objects config"}, "annotation_offset": {"default": 0, "description": "Milliseconds to shift detect annotations to better align timeline bounding boxes with recordings; can be positive or negative.", "title": "Annotation offset", "type": "integer"}}, "title": "DetectConfig", "type": "object"}, "DetectionsConfig": {"additionalProperties": false, "description": "Configure detections", "properties": {"enabled": {"default": true, "description": "Enable or disable detection events for all cameras; can be overridden per-camera.", "title": "Enable detections", "type": "boolean"}, "labels": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null, "description": "List of object labels that qualify as detection events.", "title": "Detection labels"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that an object must enter to be considered a detection; leave empty to allow any zone.", "title": "Required zones"}, "cutoff_time": {"default": 30, "description": "Seconds to wait after no detection-causing activity before cutting off a detection.", "title": "Detections cutoff time", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether detections were originally enabled in the static configuration.", "title": "Original detections state"}}, "title": "DetectionsConfig", "type": "object"}, "EnrichmentsDeviceEnum": {"enum": ["GPU", "CPU"], "title": "EnrichmentsDeviceEnum", "type": "string"}, "EventsConfig": {"additionalProperties": false, "properties": {"pre_capture": {"default": 5, "description": "Number of seconds before the detection event to include in the recording.", "maximum": 60, "minimum": 0, "title": "Pre-capture seconds", "type": "integer"}, "post_capture": {"default": 5, "description": "Number of seconds after the detection event to include in the recording.", "minimum": 0, "title": "Post-capture seconds", "type": "integer"}, "retain": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for recordings of detection events.", "title": "Event retention"}}, "title": "EventsConfig", "type": "object"}, "FaceRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable face recognition for all cameras; can be overridden per-camera.", "title": "Enable face recognition", "type": "boolean"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size to use for face embeddings (small/large); larger may require GPU.", "title": "Model size"}, "unknown_score": {"default": 0.8, "description": "Distance threshold below which a face is considered a potential match (higher = stricter).", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Unknown score threshold", "type": "number"}, "detection_threshold": {"default": 0.7, "description": "Minimum detection confidence required to consider a face detection valid.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Detection threshold", "type": "number"}, "recognition_threshold": {"default": 0.9, "description": "Face embedding distance threshold to consider two faces a match.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Recognition threshold", "type": "number"}, "min_area": {"default": 750, "description": "Minimum area (pixels) of a detected face box required to attempt recognition.", "title": "Minimum face area", "type": "integer"}, "min_faces": {"default": 1, "description": "Minimum number of face recognitions required before applying a recognized sub-label to a person.", "exclusiveMinimum": 0, "maximum": 6, "title": "Minimum faces", "type": "integer"}, "save_attempts": {"default": 200, "description": "Number of face recognition attempts to retain for recent recognition UI.", "minimum": 0, "title": "Save attempts", "type": "integer"}, "blur_confidence_filter": {"default": true, "description": "Adjust confidence scores based on image blur to reduce false positives for poor quality faces.", "title": "Blur confidence filter", "type": "boolean"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}}, "title": "FaceRecognitionConfig", "type": "object"}, "FfmpegConfig": {"additionalProperties": false, "properties": {"path": {"default": "default", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\").", "title": "FFmpeg path", "type": "string"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "description": "Global arguments passed to FFmpeg processes.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-rtsp-generic", "description": "Input arguments applied to FFmpeg input streams.", "title": "Input arguments"}, "output_args": {"$ref": "#/$defs/FfmpegOutputArgsConfig", "description": "Default output arguments used for different FFmpeg roles such as detect and record.", "title": "Output arguments"}, "retry_interval": {"default": 10.0, "description": "Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", "exclusiveMinimum": 0.0, "title": "FFmpeg retry time", "type": "number"}, "apple_compatibility": {"default": false, "description": "Enable HEVC tagging for better Apple player compatibility when recording H.265.", "title": "Apple compatibility", "type": "boolean"}, "gpu": {"default": 0, "description": "Default GPU index used for hardware acceleration if available.", "title": "GPU index", "type": "integer"}}, "title": "FfmpegConfig", "type": "object"}, "FfmpegOutputArgsConfig": {"additionalProperties": false, "properties": {"detect": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "description": "Default output arguments for detect role streams.", "title": "Detect output arguments"}, "record": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-record-generic-audio-aac", "description": "Default output arguments for record role streams.", "title": "Record output arguments"}, "record_sub": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Output arguments for record_sub role streams. The record output arguments are used when this is not set.", "title": "Sub stream record output arguments"}}, "title": "FfmpegOutputArgsConfig", "type": "object"}, "FilterConfig": {"additionalProperties": false, "properties": {"min_area": {"anyOf": [{"type": "integer"}, {"type": "number"}], "default": 0, "description": "Minimum bounding box area (pixels or percentage) required for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", "title": "Minimum object area"}, "max_area": {"anyOf": [{"type": "integer"}, {"type": "number"}], "default": 24000000, "description": "Maximum bounding box area (pixels or percentage) allowed for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", "title": "Maximum object area"}, "min_ratio": {"default": 0, "description": "Minimum width/height ratio required for the bounding box to qualify.", "title": "Minimum aspect ratio", "type": "number"}, "max_ratio": {"default": 24000000, "description": "Maximum width/height ratio allowed for the bounding box to qualify.", "title": "Maximum aspect ratio", "type": "number"}, "threshold": {"default": 0.7, "description": "Average detection confidence threshold required for the object to be considered a true positive.", "title": "Confidence threshold", "type": "number"}, "min_score": {"default": 0.5, "description": "Minimum single-frame detection confidence required for the object to be counted.", "title": "Minimum confidence", "type": "number"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "description": "Polygon coordinates defining where this filter applies within the frame.", "title": "Filter mask", "type": "object"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}}, "title": "FilterConfig", "type": "object"}, "GenAIConfig": {"additionalProperties": false, "description": "Primary GenAI Config to define GenAI Provider.", "properties": {"api_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "API key required by some providers (can also be set via environment variables).", "title": "API key"}, "base_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Base URL for self-hosted or compatible providers (for example an Ollama instance).", "title": "Base URL"}, "model": {"default": "", "description": "The model to use from the provider for generating descriptions or summaries.", "title": "Model", "type": "string"}, "provider": {"$ref": "#/$defs/GenAIProviderEnum", "description": "The GenAI provider to use (for example: ollama, gemini, openai).", "title": "Provider"}, "roles": {"description": "GenAI roles (chat, descriptions, embeddings); one provider per role.", "items": {"$ref": "#/$defs/GenAIRoleEnum"}, "title": "Roles", "type": "array"}, "provider_options": {"additionalProperties": {}, "default": {}, "description": "Additional provider-specific options to pass to the GenAI client.", "title": "Provider options", "type": "object"}, "runtime_options": {"additionalProperties": {}, "default": {}, "description": "Runtime options passed to the provider for each inference call.", "title": "Runtime options", "type": "object"}}, "required": ["provider"], "title": "GenAIConfig", "type": "object"}, "GenAIObjectConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable GenAI generation of descriptions for tracked objects by default.", "title": "Enable GenAI", "type": "boolean"}, "use_snapshot": {"default": false, "description": "Use object snapshots instead of thumbnails for GenAI description generation.", "title": "Use snapshots", "type": "boolean"}, "prompt": {"default": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "description": "Default prompt template used when generating descriptions with GenAI.", "title": "Caption prompt", "type": "string"}, "object_prompts": {"additionalProperties": {"type": "string"}, "description": "Per-object prompts to customize GenAI outputs for specific labels.", "title": "Object prompts", "type": "object"}, "objects": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "List of object labels to send to GenAI by default.", "title": "GenAI objects"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that must be entered for objects to qualify for GenAI description generation.", "title": "Required zones"}, "debug_save_thumbnails": {"default": false, "description": "Save thumbnails sent to GenAI for debugging and review.", "title": "Save thumbnails", "type": "boolean"}, "send_triggers": {"$ref": "#/$defs/GenAIObjectTriggerConfig", "description": "Defines when frames should be sent to GenAI (on end, after updates, etc.).", "title": "GenAI triggers"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether GenAI was enabled in the original static config.", "title": "Original GenAI state"}}, "title": "GenAIObjectConfig", "type": "object"}, "GenAIObjectTriggerConfig": {"additionalProperties": false, "properties": {"tracked_object_end": {"default": true, "description": "Send a request to GenAI when the tracked object ends.", "title": "Send on end", "type": "boolean"}, "after_significant_updates": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "description": "Send a request to GenAI after a specified number of significant updates for the tracked object.", "title": "Early GenAI trigger"}}, "title": "GenAIObjectTriggerConfig", "type": "object"}, "GenAIProviderEnum": {"enum": ["openai", "azure_openai", "gemini", "ollama", "llamacpp"], "title": "GenAIProviderEnum", "type": "string"}, "GenAIReviewConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable GenAI-generated descriptions and summaries for review items.", "title": "Enable GenAI descriptions", "type": "boolean"}, "alerts": {"default": true, "description": "Use GenAI to generate descriptions for alert items.", "title": "Enable GenAI for alerts", "type": "boolean"}, "detections": {"default": false, "description": "Use GenAI to generate descriptions for detection items.", "title": "Enable GenAI for detections", "type": "boolean"}, "image_source": {"$ref": "#/$defs/ImageSourceEnum", "default": "preview", "description": "Source of images sent to GenAI ('preview' or 'recordings'); 'recordings' uses higher quality frames but more tokens.", "title": "Review image source"}, "additional_concerns": {"default": [], "description": "A list of additional concerns or notes the GenAI should consider when evaluating activity on this camera.", "items": {"type": "string"}, "title": "Additional concerns", "type": "array"}, "debug_save_thumbnails": {"default": false, "description": "Save thumbnails that are sent to the GenAI provider for debugging and review.", "title": "Save thumbnails", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether GenAI review was originally enabled in the static configuration.", "title": "Original GenAI state"}, "preferred_language": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Preferred language to request from the GenAI provider for generated responses.", "title": "Preferred language"}, "activity_context_prompt": {"default": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is.", "description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries.", "title": "Activity context prompt", "type": "string"}}, "title": "GenAIReviewConfig", "type": "object"}, "GenAIRoleEnum": {"enum": ["chat", "descriptions", "embeddings"], "title": "GenAIRoleEnum", "type": "string"}, "HeaderMappingConfig": {"additionalProperties": false, "properties": {"user": {"default": null, "description": "Header containing the authenticated username provided by the upstream proxy.", "title": "User header", "type": "string"}, "role": {"default": null, "description": "Header containing the authenticated user's role or groups from the upstream proxy.", "title": "Role header", "type": "string"}, "role_map": {"anyOf": [{"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "type": "object"}, {"type": "null"}], "description": "Map upstream group values to Frigate roles (for example map admin groups to the admin role).", "title": "Role mapping"}}, "title": "HeaderMappingConfig", "type": "object"}, "IPv6Config": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable IPv6 support for Frigate services (API and UI) where applicable.", "title": "Enable IPv6", "type": "boolean"}}, "title": "IPv6Config", "type": "object"}, "ImageSourceEnum": {"description": "Image source options for GenAI Review.", "enum": ["preview", "recordings"], "title": "ImageSourceEnum", "type": "string"}, "InputDTypeEnum": {"enum": ["float", "float_denorm", "int"], "title": "InputDTypeEnum", "type": "string"}, "InputTensorEnum": {"enum": ["nchw", "nhwc", "hwnc", "hwcn"], "title": "InputTensorEnum", "type": "string"}, "LicensePlateRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable license plate recognition for all cameras; can be overridden per-camera.", "title": "Enable LPR", "type": "boolean"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size used for text detection/recognition. Most users should use 'small'.", "title": "Model size"}, "detection_threshold": {"default": 0.7, "description": "Detection confidence threshold to begin running OCR on a suspected plate.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Detection threshold", "type": "number"}, "min_area": {"default": 1000, "description": "Minimum plate area (pixels) required to attempt recognition.", "title": "Minimum plate area", "type": "integer"}, "recognition_threshold": {"default": 0.9, "description": "Confidence threshold required for recognized plate text to be attached as a sub-label.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Recognition threshold", "type": "number"}, "min_plate_length": {"default": 4, "description": "Minimum number of characters a recognized plate must contain to be considered valid.", "title": "Min plate length", "type": "integer"}, "format": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional regex to validate recognized plate strings against an expected format.", "title": "Plate format regex"}, "match_distance": {"default": 1, "description": "Number of character mismatches allowed when comparing detected plates to known plates.", "minimum": 0, "title": "Match distance", "type": "integer"}, "known_plates": {"anyOf": [{"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "type": "object"}, {"type": "null"}], "default": {}, "description": "List of plates or regexes to specially track or alert on.", "title": "Known plates"}, "enhancement": {"default": 0, "description": "Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", "maximum": 10, "minimum": 0, "title": "Enhancement level", "type": "integer"}, "debug_save_plates": {"default": false, "description": "Save plate crop images for debugging LPR performance.", "title": "Save debug plates", "type": "boolean"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}, "replace_rules": {"description": "Regex replacement rules used to normalize detected plate strings before matching.", "items": {"$ref": "#/$defs/ReplaceRule"}, "title": "Replacement rules", "type": "array"}}, "title": "LicensePlateRecognitionConfig", "type": "object"}, "ListenConfig": {"additionalProperties": false, "properties": {"internal": {"anyOf": [{"type": "integer"}, {"type": "string"}], "default": 5000, "description": "Internal listening port for Frigate (default 5000).", "title": "Internal port"}, "external": {"anyOf": [{"type": "integer"}, {"type": "string"}], "default": 8971, "description": "External listening port for Frigate (default 8971).", "title": "External port"}}, "title": "ListenConfig", "type": "object"}, "LogLevel": {"enum": ["debug", "info", "warning", "error", "critical"], "title": "LogLevel", "type": "string"}, "LoggerConfig": {"additionalProperties": false, "properties": {"default": {"$ref": "#/$defs/LogLevel", "default": "info", "title": "Logging level", "description": "Default global log verbosity (debug, info, warning, error)."}, "logs": {"additionalProperties": {"$ref": "#/$defs/LogLevel"}, "description": "Per-component log level overrides to increase or decrease verbosity for specific modules.", "title": "Per-process log level", "type": "object"}}, "title": "LoggerConfig", "type": "object"}, "ModelConfig": {"additionalProperties": false, "properties": {"scene": {"$ref": "#/$defs/SceneEnum", "default": "all", "description": "The camera environment this model is used for. Cameras select a model by setting detect.scene to a matching value, and 'all' is used by any camera that does not set one.", "title": "Model scene"}, "devices": {"description": "Hardware this model runs on, as '' or ':' (for example 'edgetpu:pci:0' or 'openvino:GPU'). Listing the same device more than once runs additional inference processes on it.", "items": {"type": "string"}, "title": "Detection hardware", "type": "array"}, "path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to a custom detection model file (or plus:// for Frigate+ models).", "title": "Custom object detector model path"}, "labelmap_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to a labelmap file that maps numeric classes to string labels for the detector.", "title": "Label map for custom object detector"}, "width": {"default": 320, "description": "Width of the model input tensor in pixels.", "title": "Object detection model input width", "type": "integer"}, "height": {"default": 320, "description": "Height of the model input tensor in pixels.", "title": "Object detection model input height", "type": "integer"}, "labelmap": {"additionalProperties": {"type": "string"}, "description": "Overrides or remapping entries to merge into the standard labelmap.", "title": "Labelmap customization", "type": "object"}, "attributes_map": {"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "default": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate']).", "title": "Map of object labels to their attribute labels", "type": "object"}, "input_tensor": {"$ref": "#/$defs/InputTensorEnum", "default": "nhwc", "description": "Tensor format expected by the model: 'nhwc' or 'nchw'.", "title": "Model Input Tensor Shape"}, "input_pixel_format": {"$ref": "#/$defs/PixelFormatEnum", "default": "rgb", "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'.", "title": "Model Input Pixel Color Format"}, "input_dtype": {"$ref": "#/$defs/InputDTypeEnum", "default": "int", "description": "Data type of the model input tensor (for example 'float32').", "title": "Model Input D Type"}, "model_type": {"$ref": "#/$defs/ModelTypeEnum", "default": "ssd", "description": "Detector model architecture type (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine) used by some detectors for optimization.", "title": "Object Detection Model Type"}}, "title": "ModelConfig", "type": "object"}, "ModelSizeEnum": {"enum": ["small", "large"], "title": "ModelSizeEnum", "type": "string"}, "ModelTypeEnum": {"enum": ["dfine", "rfdetr", "ssd", "yolox", "yolonas", "yolo-generic"], "title": "ModelTypeEnum", "type": "string"}, "MotionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable motion detection for all cameras; can be overridden per-camera.", "title": "Enable motion detection", "type": "boolean"}, "threshold": {"default": 30, "description": "Pixel difference threshold used by the motion detector; higher values reduce sensitivity (range 1-255).", "maximum": 255, "minimum": 1, "title": "Motion threshold", "type": "integer"}, "lightning_threshold": {"default": 0.8, "description": "Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0). This does not prevent motion detection entirely; it merely causes the detector to stop analyzing additional frames once the threshold is exceeded. Motion-based recordings are still created during these events.", "maximum": 1.0, "minimum": 0.3, "title": "Lightning threshold", "type": "number"}, "skip_motion_threshold": {"anyOf": [{"maximum": 1.0, "minimum": 0.0, "type": "number"}, {"type": "null"}], "default": null, "description": "If set to a value between 0.0 and 1.0, and more than this fraction of the image changes in a single frame, the detector will return no motion boxes and immediately recalibrate. This can save CPU and reduce false positives during lightning, storms, etc., but may miss real events such as a PTZ camera auto\u2011tracking an object. The trade\u2011off is between dropping a few megabytes of recordings versus reviewing a couple short clips. Leave unset (None) to disable this feature.", "title": "Skip motion threshold"}, "improve_contrast": {"default": true, "description": "Apply contrast improvement to frames before motion analysis to help detection.", "title": "Improve contrast", "type": "boolean"}, "contour_area": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 10, "description": "Minimum contour area in pixels required for a motion contour to be counted.", "title": "Contour area"}, "delta_alpha": {"default": 0.2, "description": "Alpha blending factor used in frame differencing for motion calculation.", "title": "Delta alpha", "type": "number"}, "frame_alpha": {"default": 0.01, "description": "Alpha value used when blending frames for motion preprocessing.", "title": "Frame alpha", "type": "number"}, "frame_height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 100, "description": "Height in pixels to scale frames to when computing motion.", "title": "Frame height"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/MotionMaskConfig"}, {"type": "null"}]}, "description": "Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", "title": "Mask coordinates", "type": "object"}, "mqtt_off_delay": {"default": 30, "description": "Seconds to wait after last motion before publishing an MQTT 'off' state.", "title": "MQTT off delay", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether motion detection was enabled in the original static configuration.", "title": "Original motion state"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/MotionMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}}, "title": "MotionConfig", "type": "object"}, "MotionMaskConfig": {"additionalProperties": false, "description": "Configuration for a single motion mask.", "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A friendly name for this motion mask used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this motion mask", "title": "Enabled", "type": "boolean"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "description": "Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", "title": "Coordinates"}, "raw_coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "title": "Raw Coordinates"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of motion mask."}}, "title": "MotionMaskConfig", "type": "object"}, "MqttConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable MQTT integration for state, events, and snapshots.", "title": "Enable MQTT", "type": "boolean"}, "host": {"default": "", "description": "Hostname or IP address of the MQTT broker.", "title": "MQTT host", "type": "string"}, "port": {"default": 1883, "description": "Port of the MQTT broker (usually 1883 for plain MQTT).", "title": "MQTT port", "type": "integer"}, "topic_prefix": {"default": "frigate", "description": "MQTT topic prefix for all Frigate topics; must be unique if running multiple instances.", "title": "Topic prefix", "type": "string"}, "client_id": {"default": "frigate", "description": "Client identifier used when connecting to the MQTT broker; should be unique per instance.", "title": "Client ID", "type": "string"}, "stats_interval": {"default": 60, "description": "Interval in seconds for publishing system and camera stats to MQTT.", "minimum": 15, "title": "Stats interval", "type": "integer"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional MQTT username; can be provided via environment variables or secrets.", "title": "MQTT username"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional MQTT password; can be provided via environment variables or secrets.", "title": "MQTT password"}, "tls_ca_certs": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to CA certificate for TLS connections to the broker (for self-signed certs).", "title": "TLS CA certs"}, "tls_client_cert": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Client certificate path for TLS mutual authentication; do not set user/password when using client certs.", "title": "Client cert"}, "tls_client_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Private key path for the client certificate.", "title": "Client key"}, "tls_insecure": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Allow insecure TLS connections by skipping hostname verification (not recommended).", "title": "TLS insecure"}, "qos": {"default": 0, "description": "Quality of Service level for MQTT publishes/subscriptions (0, 1, or 2).", "title": "MQTT QoS", "type": "integer"}}, "title": "MqttConfig", "type": "object"}, "NetworkingConfig": {"additionalProperties": false, "properties": {"ipv6": {"$ref": "#/$defs/IPv6Config", "description": "IPv6-specific settings for Frigate network services.", "title": "IPv6 configuration"}, "listen": {"$ref": "#/$defs/ListenConfig", "description": "Configuration for internal and external listening ports. This is for advanced users. For the majority of use cases it's recommended to change the ports section of your Docker compose file.", "title": "Listening ports configuration"}}, "title": "NetworkingConfig", "type": "object"}, "NotificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable notifications for all cameras; can be overridden per-camera.", "title": "Enable notifications", "type": "boolean"}, "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Email address used for push notifications or required by certain notification providers.", "title": "Notification email"}, "cooldown": {"default": 0, "description": "Cooldown (seconds) between notifications to avoid spamming recipients.", "minimum": 0, "title": "Cooldown period", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether notifications were enabled in the original static configuration.", "title": "Original notifications state"}}, "title": "NotificationConfig", "type": "object"}, "ObjectClassificationType": {"enum": ["sub_label", "attribute"], "title": "ObjectClassificationType", "type": "string"}, "ObjectConfig": {"additionalProperties": false, "properties": {"track": {"default": ["person"], "description": "List of object labels to track for all cameras; can be overridden per-camera.", "items": {"type": "string"}, "title": "Objects to track", "type": "array"}, "filters": {"additionalProperties": {"$ref": "#/$defs/FilterConfig"}, "description": "Filters applied to detected objects to reduce false positives (area, ratio, confidence).", "title": "Object filters", "type": "object"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "description": "Mask polygon used to prevent object detection in specified areas.", "title": "Object mask", "type": "object"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}, "genai": {"$ref": "#/$defs/GenAIObjectConfig", "description": "GenAI options for describing tracked objects and sending frames for generation.", "title": "GenAI object config"}}, "title": "ObjectConfig", "type": "object"}, "ObjectMaskConfig": {"additionalProperties": false, "description": "Configuration for a single object mask.", "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A friendly name for this object mask used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this object mask", "title": "Enabled", "type": "boolean"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "description": "Ordered x,y coordinates defining the object mask polygon used to include/exclude areas.", "title": "Coordinates"}, "raw_coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "title": "Raw Coordinates"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of object mask."}}, "title": "ObjectMaskConfig", "type": "object"}, "OnvifConfig": {"additionalProperties": false, "properties": {"host": {"default": "", "description": "Host (and optional scheme) for the ONVIF service for this camera.", "title": "ONVIF host", "type": "string"}, "port": {"default": 8000, "description": "Port number for the ONVIF service.", "title": "ONVIF port", "type": "integer"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Username for ONVIF authentication; some devices require admin user for ONVIF.", "title": "ONVIF username"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Password for ONVIF authentication.", "title": "ONVIF password"}, "tls_insecure": {"default": false, "description": "Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only).", "title": "Disable TLS verify", "type": "boolean"}, "profile": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically.", "title": "ONVIF profile"}, "autotracking": {"$ref": "#/$defs/PtzAutotrackConfig", "description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.", "title": "Autotracking"}, "ignore_time_mismatch": {"default": false, "description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication.", "title": "Ignore time mismatch", "type": "boolean"}}, "title": "OnvifConfig", "type": "object"}, "PixelFormatEnum": {"enum": ["rgb", "bgr", "yuv"], "title": "PixelFormatEnum", "type": "string"}, "ProfileDefinitionConfig": {"additionalProperties": false, "description": "Defines a named profile with a human-readable display name.\n\nThe dict key is the machine name used internally; friendly_name\nis the label shown in the UI and API responses.", "properties": {"friendly_name": {"description": "Display name for this profile shown in the UI.", "title": "Friendly name", "type": "string"}}, "required": ["friendly_name"], "title": "ProfileDefinitionConfig", "type": "object"}, "ProxyConfig": {"additionalProperties": false, "properties": {"header_map": {"$ref": "#/$defs/HeaderMappingConfig", "description": "Map incoming proxy headers to Frigate user and role fields for proxy-based auth.", "title": "Header mapping"}, "logout_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "URL to redirect users to when logging out via the proxy.", "title": "Logout URL"}, "auth_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional secret checked against the X-Proxy-Secret header to verify trusted proxies.", "title": "Proxy secret"}, "default_role": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": "viewer", "description": "Default role assigned to proxy-authenticated users when no role mapping applies.", "title": "Default role"}, "separator": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": ",", "description": "Character used to split multiple values provided in proxy headers.", "title": "Separator character"}}, "title": "ProxyConfig", "type": "object"}, "PtzAutotrackConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable automatic PTZ camera tracking of detected objects.", "title": "Enable Autotracking", "type": "boolean"}, "calibrate_on_startup": {"default": false, "description": "Measure PTZ motor speeds on startup to improve tracking accuracy. Frigate will update config with movement_weights after calibration.", "title": "Calibrate on start", "type": "boolean"}, "zooming": {"$ref": "#/$defs/ZoomingModeEnum", "default": "disabled", "description": "Control zoom behavior: disabled (pan/tilt only), absolute (most compatible), or relative (concurrent pan/tilt/zoom).", "title": "Zoom mode"}, "zoom_factor": {"default": 0.3, "description": "Control zoom level on tracked objects. Lower values keep more scene in view; higher values zoom in closer but may lose tracking. Values between 0.1 and 0.75.", "maximum": 0.75, "minimum": 0.1, "title": "Zoom factor", "type": "number"}, "track": {"default": ["person"], "description": "List of object types that should trigger autotracking.", "items": {"type": "string"}, "title": "Tracked objects", "type": "array"}, "required_zones": {"description": "Objects must enter one of these zones before autotracking begins.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "return_preset": {"default": "home", "description": "ONVIF preset name configured in camera firmware to return to after tracking ends.", "title": "Return preset", "type": "string"}, "timeout": {"default": 10, "description": "Wait this many seconds after losing tracking before returning camera to preset position.", "title": "Return timeout", "type": "integer"}, "movement_weights": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "description": "Calibration values automatically generated by camera calibration. Do not modify manually.", "title": "Movement weights"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Internal field to track whether autotracking was enabled in configuration.", "title": "Original autotrack state"}}, "title": "PtzAutotrackConfig", "type": "object"}, "RecordConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable recording for all cameras; can be overridden per-camera.", "title": "Enable recording", "type": "boolean"}, "expire_interval": {"default": 60, "description": "Minutes between cleanup passes that remove expired recording segments.", "title": "Record cleanup interval", "type": "integer"}, "continuous": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain recordings regardless of tracked objects or motion. Set to 0 if you only want to retain recordings of alerts and detections.", "title": "Continuous retention"}, "motion": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain recordings triggered by motion regardless of tracked objects. Set to 0 if you only want to retain recordings of alerts and detections.", "title": "Motion retention"}, "detections": {"$ref": "#/$defs/EventsConfig", "description": "Recording retention settings for detection events including pre/post capture durations.", "title": "Detection retention"}, "alerts": {"$ref": "#/$defs/EventsConfig", "description": "Recording retention settings for alert events including pre/post capture durations.", "title": "Alert retention"}, "export": {"$ref": "#/$defs/RecordExportConfig", "description": "Settings used when exporting recordings such as timelapse and hardware acceleration.", "title": "Export config"}, "preview": {"$ref": "#/$defs/RecordPreviewConfig", "description": "Settings controlling the quality of recording previews shown in the UI.", "title": "Preview config"}, "sub": {"$ref": "#/$defs/RecordSubConfig", "description": "Settings for recording a second, lower quality stream.", "title": "Sub stream recording"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether recording was enabled in the original static configuration.", "title": "Original recording state"}}, "title": "RecordConfig", "type": "object"}, "RecordExportConfig": {"additionalProperties": false, "properties": {"hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration args to use for export/transcode operations.", "title": "Export hwaccel args"}, "max_concurrent": {"default": 3, "description": "Maximum number of export jobs to process at the same time.", "minimum": 1, "title": "Maximum concurrent exports", "type": "integer"}, "chapters": {"$ref": "#/$defs/ChaptersEnum", "default": "review_items", "title": "Chapter metadata to embed in exported recordings"}}, "title": "RecordExportConfig", "type": "object"}, "RecordPreviewConfig": {"additionalProperties": false, "properties": {"quality": {"$ref": "#/$defs/RecordQualityEnum", "default": "medium", "description": "Preview quality level (very_low, low, medium, high, very_high).", "title": "Preview quality"}}, "title": "RecordPreviewConfig", "type": "object"}, "RecordQualityEnum": {"enum": ["very_low", "low", "medium", "high", "very_high"], "title": "RecordQualityEnum", "type": "string"}, "RecordRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 0, "description": "Days to retain recordings.", "minimum": 0.0, "title": "Retention days", "type": "number"}}, "title": "RecordRetainConfig", "type": "object"}, "RecordSubConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable recording of a second, lower quality stream for adaptive quality playback and extended retention.", "title": "Enable sub stream recording", "type": "boolean"}, "continuous": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain sub stream recordings regardless of tracked objects or motion.", "title": "Sub stream continuous retention"}, "motion": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain sub stream recordings triggered by motion.", "title": "Sub stream motion retention"}, "alerts": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for sub stream recordings of alerts.", "title": "Sub stream alert retention"}, "detections": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for sub stream recordings of detections.", "title": "Sub stream detection retention"}}, "title": "RecordSubConfig", "type": "object"}, "ReplaceRule": {"additionalProperties": false, "properties": {"pattern": {"title": "Regex pattern", "type": "string"}, "replacement": {"title": "Replacement string", "type": "string"}}, "required": ["pattern", "replacement"], "title": "ReplaceRule", "type": "object"}, "RestreamConfig": {"additionalProperties": true, "properties": {}, "title": "RestreamConfig", "type": "object"}, "RetainConfig": {"additionalProperties": false, "properties": {"default": {"type": "number", "default": 10, "title": "Default retention", "description": "Default number of days to retain snapshots."}, "objects": {"additionalProperties": {"type": "number"}, "description": "Per-object overrides for snapshot retention days.", "title": "Object retention", "type": "object"}}, "title": "RetainConfig", "type": "object"}, "RetainModeEnum": {"enum": ["all", "motion", "active_objects"], "title": "RetainModeEnum", "type": "string"}, "ReviewConfig": {"additionalProperties": false, "properties": {"alerts": {"$ref": "#/$defs/AlertsConfig", "description": "Settings for which tracked objects generate alerts and how alerts are retained.", "title": "Alerts config"}, "detections": {"$ref": "#/$defs/DetectionsConfig", "description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.", "title": "Detections config"}, "genai": {"$ref": "#/$defs/GenAIReviewConfig", "description": "Controls use of generative AI for producing descriptions and summaries of review items.", "title": "GenAI config"}}, "title": "ReviewConfig", "type": "object"}, "ReviewRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 10, "description": "Number of days to retain recordings of detection events.", "minimum": 0.0, "title": "Retention days", "type": "number"}, "mode": {"$ref": "#/$defs/RetainModeEnum", "default": "motion", "description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).", "title": "Retention mode"}}, "title": "ReviewRetainConfig", "type": "object"}, "SceneEnum": {"description": "The camera environment a detection model is intended for.", "enum": ["all", "indoor", "outdoor", "indoor_thermal", "outdoor_thermal"], "title": "SceneEnum", "type": "string"}, "SemanticSearchConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable the semantic search feature.", "title": "Enable semantic search", "type": "boolean"}, "reindex": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Trigger a full reindex of historical tracked objects into the embeddings database.", "title": "Reindex on startup"}, "model": {"anyOf": [{"$ref": "#/$defs/SemanticSearchModelEnum"}, {"type": "string"}, {"type": "null"}], "default": "jinav1", "description": "The embeddings model to use for semantic search (for example 'jinav1'), or the name of a GenAI provider with the embeddings role.", "title": "Semantic search model or GenAI provider name"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Select model size; 'small' runs on CPU and 'large' typically requires GPU.", "title": "Model size"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}}, "title": "SemanticSearchConfig", "type": "object"}, "SemanticSearchModelEnum": {"enum": ["jinav1", "jinav2"], "title": "SemanticSearchModelEnum", "type": "string"}, "SnapshotsConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable saving snapshots for all cameras; can be overridden per-camera.", "title": "Enable snapshots", "type": "boolean"}, "timestamp": {"default": false, "description": "Overlay a timestamp on snapshots from API.", "title": "Timestamp overlay", "type": "boolean"}, "bounding_box": {"default": true, "description": "Draw bounding boxes for tracked objects on snapshots from API.", "title": "Bounding box overlay", "type": "boolean"}, "crop": {"default": false, "description": "Crop snapshots from API to the detected object's bounding box.", "title": "Crop snapshot", "type": "boolean"}, "required_zones": {"description": "Zones an object must enter for a snapshot to be saved.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Height (pixels) to resize snapshots from API to; leave empty to preserve original size.", "title": "Snapshot height"}, "retain": {"$ref": "#/$defs/RetainConfig", "description": "Retention settings for snapshots including default days and per-object overrides.", "title": "Snapshot retention"}, "quality": {"default": 60, "description": "Encode quality for saved snapshots (0-100).", "maximum": 100, "minimum": 0, "title": "Snapshot quality", "type": "integer"}}, "title": "SnapshotsConfig", "type": "object"}, "StationaryConfig": {"additionalProperties": false, "properties": {"interval": {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "How often (in frames) to run a detection check to confirm a stationary object.", "title": "Stationary interval"}, "threshold": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "description": "Number of frames with no position change required to mark an object as stationary.", "title": "Stationary threshold"}, "max_frames": {"$ref": "#/$defs/StationaryMaxFramesConfig", "description": "Limits how long stationary objects are tracked before being discarded.", "title": "Max frames"}, "classifier": {"default": true, "description": "Use a visual classifier to detect truly stationary objects even when bounding boxes jitter.", "title": "Enable visual classifier", "type": "boolean"}}, "title": "StationaryConfig", "type": "object"}, "StationaryMaxFramesConfig": {"additionalProperties": false, "properties": {"default": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "title": "Default max frames", "description": "Default maximum frames to track a stationary object before stopping."}, "objects": {"additionalProperties": {"type": "integer"}, "description": "Per-object overrides for maximum frames to track stationary objects.", "title": "Object max frames", "type": "object"}}, "title": "StationaryMaxFramesConfig", "type": "object"}, "StatsConfig": {"additionalProperties": false, "properties": {"amd_gpu_stats": {"default": true, "description": "Enable collection of AMD GPU statistics if an AMD GPU is present.", "title": "AMD GPU stats", "type": "boolean"}, "intel_gpu_stats": {"default": true, "description": "Enable collection of Intel GPU statistics if an Intel GPU is present.", "title": "Intel GPU stats", "type": "boolean"}, "network_bandwidth": {"default": false, "description": "Enable per-process network bandwidth monitoring for camera ffmpeg processes and detectors (requires capabilities).", "title": "Network bandwidth", "type": "boolean"}, "intel_gpu_device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present.", "title": "Intel GPU device"}}, "title": "StatsConfig", "type": "object"}, "TelemetryConfig": {"additionalProperties": false, "properties": {"network_interfaces": {"default": [], "description": "List of network interface name prefixes to monitor for bandwidth statistics.", "items": {"type": "string"}, "title": "Network interfaces", "type": "array"}, "stats": {"$ref": "#/$defs/StatsConfig", "description": "Options to enable/disable collection of various system and GPU statistics.", "title": "System stats"}, "version_check": {"default": true, "description": "Enable an outbound check to detect if a newer Frigate version is available.", "title": "Version check", "type": "boolean"}}, "title": "TelemetryConfig", "type": "object"}, "TimeFormatEnum": {"enum": ["browser", "12hour", "24hour"], "title": "TimeFormatEnum", "type": "string"}, "TimestampEffectEnum": {"enum": ["solid", "shadow"], "title": "TimestampEffectEnum", "type": "string"}, "TimestampPositionEnum": {"enum": ["tl", "tr", "bl", "br"], "title": "TimestampPositionEnum", "type": "string"}, "TimestampStyleConfig": {"additionalProperties": false, "properties": {"position": {"$ref": "#/$defs/TimestampPositionEnum", "default": "tl", "description": "Position of the timestamp on the image (tl/tr/bl/br).", "title": "Timestamp position"}, "format": {"default": "%m/%d/%Y %H:%M:%S", "description": "Datetime format string used for timestamps (Python datetime format codes).", "title": "Timestamp format", "type": "string"}, "color": {"$ref": "#/$defs/ColorConfig", "description": "RGB color values for the timestamp text (all values 0-255).", "title": "Timestamp color"}, "thickness": {"default": 2, "description": "Line thickness of the timestamp text.", "title": "Timestamp thickness", "type": "integer"}, "effect": {"anyOf": [{"$ref": "#/$defs/TimestampEffectEnum"}, {"type": "null"}], "default": null, "description": "Visual effect for the timestamp text (none, solid, shadow).", "title": "Timestamp effect"}}, "title": "TimestampStyleConfig", "type": "object"}, "TlsConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable TLS for Frigate's web UI and API on the configured TLS port.", "title": "Enable TLS", "type": "boolean"}}, "title": "TlsConfig", "type": "object"}, "TriggerAction": {"enum": ["notification", "sub_label", "attribute"], "title": "TriggerAction", "type": "string"}, "TriggerConfig": {"additionalProperties": false, "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional friendly name displayed in the UI for this trigger.", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this semantic search trigger.", "title": "Enable this trigger", "type": "boolean"}, "type": {"$ref": "#/$defs/TriggerType", "default": "description", "description": "Type of trigger: 'thumbnail' (match against image) or 'description' (match against text).", "title": "Trigger type"}, "data": {"description": "Text phrase or thumbnail ID to match against tracked objects.", "title": "Trigger content", "type": "string"}, "threshold": {"default": 0.8, "description": "Minimum similarity score (0-1) required to activate this trigger.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Trigger threshold", "type": "number"}, "actions": {"default": [], "description": "List of actions to execute when trigger matches (notification, sub_label, attribute).", "items": {"$ref": "#/$defs/TriggerAction"}, "title": "Trigger actions", "type": "array"}}, "required": ["data"], "title": "TriggerConfig", "type": "object"}, "TriggerType": {"enum": ["thumbnail", "description"], "title": "TriggerType", "type": "string"}, "UIConfig": {"additionalProperties": false, "properties": {"timezone": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional timezone to display across the UI (defaults to browser local time if unset).", "title": "Timezone"}, "time_format": {"$ref": "#/$defs/TimeFormatEnum", "default": "browser", "description": "Time format to use in the UI (browser, 12hour, or 24hour).", "title": "Time format"}, "unit_system": {"$ref": "#/$defs/UnitSystemEnum", "default": "metric", "description": "Unit system for display (metric or imperial) used in the UI and MQTT.", "title": "Unit system"}}, "title": "UIConfig", "type": "object"}, "UnitSystemEnum": {"enum": ["imperial", "metric"], "title": "UnitSystemEnum", "type": "string"}, "ZoneConfig": {"properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A user-friendly name for the zone, displayed in the Frigate UI. If not set, a formatted version of the zone name will be used.", "title": "Zone name"}, "enabled": {"default": true, "description": "Enable or disable this zone. Disabled zones are ignored at runtime.", "title": "Enabled", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of zone."}, "filters": {"additionalProperties": {"$ref": "#/$defs/FilterConfig"}, "description": "Filters to apply to objects within this zone. Used to reduce false positives or restrict which objects are considered present in the zone.", "title": "Zone filters", "type": "object"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Polygon coordinates that define the zone area. Can be a comma-separated string or a list of coordinate strings. Coordinates should be relative (0-1) or absolute (legacy).", "title": "Coordinates"}, "distances": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "description": "Optional real-world distances for each side of the zone quadrilateral, used for speed or distance calculations. Must have exactly 4 values if set.", "title": "Real-world distances"}, "inertia": {"default": 3, "description": "Number of consecutive frames an object must be detected in the zone before it is considered present. Helps filter out transient detections.", "exclusiveMinimum": 0, "title": "Inertia frames", "type": "integer"}, "loitering_time": {"default": 0, "description": "Number of seconds an object must remain in the zone to be considered as loitering. Set to 0 to disable loitering detection.", "minimum": 0, "title": "Loitering seconds", "type": "integer"}, "speed_threshold": {"anyOf": [{"minimum": 0.1, "type": "number"}, {"type": "null"}], "default": null, "description": "Minimum speed (in real-world units if distances are set) required for an object to be considered present in the zone. Used for speed-based zone triggers.", "title": "Minimum speed"}, "objects": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "List of object types (from labelmap) that can trigger this zone. Can be a string or a list of strings. If empty, all objects are considered.", "title": "Trigger objects"}}, "required": ["coordinates"], "title": "ZoneConfig", "type": "object"}, "ZoomingModeEnum": {"enum": ["disabled", "absolute", "relative"], "title": "ZoomingModeEnum", "type": "string"}}, "additionalProperties": false, "properties": {"version": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Numeric or string version of the active configuration to help detect migrations or format changes.", "title": "Current config version"}, "safe_mode": {"default": false, "description": "When enabled, start Frigate in safe mode with reduced features for troubleshooting.", "title": "Safe mode", "type": "boolean"}, "environment_vars": {"additionalProperties": {"type": "string"}, "description": "Key/value pairs of environment variables to set for the Frigate process in Home Assistant OS. Non-HAOS users must use Docker environment variable configuration instead.", "title": "Environment variables", "type": "object"}, "logger": {"$ref": "#/$defs/LoggerConfig", "description": "Controls default log verbosity and per-component log level overrides.", "title": "Logging"}, "auth": {"$ref": "#/$defs/AuthConfig", "description": "Authentication and session-related settings including cookie and rate limit options.", "title": "Authentication"}, "database": {"$ref": "#/$defs/DatabaseConfig", "description": "Settings for the SQLite database used by Frigate to store tracked object and recording metadata.", "title": "Database"}, "go2rtc": {"$ref": "#/$defs/RestreamConfig", "description": "Settings for the integrated go2rtc restreaming service used for live stream relaying and translation.", "title": "go2rtc"}, "mqtt": {"$ref": "#/$defs/MqttConfig", "description": "Settings for connecting and publishing telemetry, snapshots, and event details to an MQTT broker.", "title": "MQTT"}, "notifications": {"$ref": "#/$defs/NotificationConfig", "description": "Settings to enable and control notifications for all cameras; can be overridden per-camera.", "title": "Notifications"}, "networking": {"$ref": "#/$defs/NetworkingConfig", "description": "Network-related settings such as IPv6 enablement for Frigate endpoints.", "title": "Networking"}, "proxy": {"$ref": "#/$defs/ProxyConfig", "description": "Settings for integrating Frigate behind a reverse proxy that passes authenticated user headers.", "title": "Proxy"}, "telemetry": {"$ref": "#/$defs/TelemetryConfig", "description": "System telemetry and stats options including GPU and network bandwidth monitoring.", "title": "Telemetry"}, "tls": {"$ref": "#/$defs/TlsConfig", "description": "TLS settings for Frigate's web endpoints (port 8971).", "title": "TLS"}, "ui": {"$ref": "#/$defs/UIConfig", "description": "User interface preferences such as timezone, time/date formatting, and units.", "title": "UI"}, "models": {"description": "Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene.", "items": {"$ref": "#/$defs/ModelConfig"}, "title": "Detection models", "type": "array"}, "genai": {"additionalProperties": {"$ref": "#/$defs/GenAIConfig"}, "description": "Settings for integrated generative AI providers used to generate object descriptions and review summaries.", "title": "Generative AI configuration", "type": "object"}, "cameras": {"additionalProperties": {"$ref": "#/$defs/CameraConfig"}, "description": "Cameras", "title": "Cameras", "type": "object"}, "audio": {"$ref": "#/$defs/AudioConfig", "description": "Settings for audio-based event detection for all cameras; can be overridden per-camera.", "title": "Audio detection"}, "birdseye": {"$ref": "#/$defs/BirdseyeConfig", "description": "Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", "title": "Birdseye"}, "detect": {"$ref": "#/$defs/DetectConfig", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "title": "Object Detection"}, "ffmpeg": {"$ref": "#/$defs/FfmpegConfig", "description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", "title": "FFmpeg"}, "live": {"$ref": "#/$defs/CameraLiveConfig", "description": "Settings to control the jsmpeg live stream resolution and quality. This does not affect restreamed cameras that use go2rtc for live view.", "title": "Live playback"}, "motion": {"anyOf": [{"$ref": "#/$defs/MotionConfig"}, {"type": "null"}], "default": null, "description": "Default motion detection settings applied to cameras unless overridden per-camera.", "title": "Motion detection"}, "objects": {"$ref": "#/$defs/ObjectConfig", "description": "Object tracking defaults including which labels to track and per-object filters.", "title": "Objects"}, "record": {"$ref": "#/$defs/RecordConfig", "description": "Recording and retention settings applied to cameras unless overridden per-camera.", "title": "Recording"}, "review": {"$ref": "#/$defs/ReviewConfig", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage.", "title": "Review"}, "snapshots": {"$ref": "#/$defs/SnapshotsConfig", "description": "Settings for API-generated snapshots of tracked objects for all cameras; can be overridden per-camera.", "title": "Snapshots"}, "timestamp_style": {"$ref": "#/$defs/TimestampStyleConfig", "description": "Styling options for in-feed timestamps applied to debug view and snapshots.", "title": "Timestamp style"}, "audio_transcription": {"$ref": "#/$defs/AudioTranscriptionConfig", "description": "Settings for live and speech audio transcription used for events and live captions.", "title": "Audio transcription"}, "classification": {"$ref": "#/$defs/ClassificationConfig", "description": "Settings for classification models used to refine object labels or state classification.", "title": "Object classification"}, "semantic_search": {"$ref": "#/$defs/SemanticSearchConfig", "description": "Settings for Semantic Search which builds and queries object embeddings to find similar items.", "title": "Semantic Search"}, "face_recognition": {"$ref": "#/$defs/FaceRecognitionConfig", "description": "Settings for face detection and recognition for all cameras; can be overridden per-camera.", "title": "Face recognition"}, "lpr": {"$ref": "#/$defs/LicensePlateRecognitionConfig", "description": "License plate recognition settings including detection thresholds, formatting, and known plates.", "title": "License Plate Recognition"}, "camera_groups": {"additionalProperties": {"$ref": "#/$defs/CameraGroupConfig"}, "description": "Configuration for named camera groups used to organize cameras in the UI.", "title": "Camera groups", "type": "object"}, "profiles": {"additionalProperties": {"$ref": "#/$defs/ProfileDefinitionConfig"}, "description": "Named profile definitions with friendly names. Camera profiles must reference names defined here.", "title": "Profiles", "type": "object"}, "active_profile": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Currently active profile name. Runtime-only, not persisted in YAML.", "title": "Active profile"}}, "required": ["mqtt", "cameras"], "title": "FrigateConfig", "type": "object"} \ No newline at end of file +{"$defs": {"AlertsConfig": {"additionalProperties": false, "description": "Configure alerts", "properties": {"enabled": {"default": true, "description": "Enable or disable alert generation for all cameras; can be overridden per-camera.", "title": "Enable alerts", "type": "boolean"}, "labels": {"default": ["person", "car"], "description": "List of object labels that qualify as alerts (for example: car, person).", "items": {"type": "string"}, "title": "Alert labels", "type": "array"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that an object must enter to be considered an alert; leave empty to allow any zone.", "title": "Required zones"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether alerts were originally enabled in the static configuration.", "title": "Original alerts state"}, "cutoff_time": {"default": 40, "description": "Seconds to wait after no alert-causing activity before cutting off an alert.", "title": "Alerts cutoff time", "type": "integer"}}, "title": "AlertsConfig", "type": "object"}, "AudioConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable audio event detection for all cameras; can be overridden per-camera.", "title": "Enable audio detection", "type": "boolean"}, "max_not_heard": {"default": 30, "description": "Amount of seconds without the configured audio type before the audio event is ended.", "title": "End timeout", "type": "integer"}, "min_volume": {"default": 500, "description": "Minimum RMS volume threshold required to run audio detection; lower values increase sensitivity (e.g., 200 high, 500 medium, 1000 low).", "title": "Minimum volume", "type": "integer"}, "listen": {"default": ["bark", "fire_alarm", "speech", "yell"], "description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell).", "items": {"type": "string"}, "title": "Listen types", "type": "array"}, "labelmap": {"additionalProperties": {"type": "string"}, "description": "Overrides or remapping entries to merge into the standard audio labelmap.", "title": "Audio labelmap customization", "type": "object"}, "filters": {"anyOf": [{"additionalProperties": {"$ref": "#/$defs/AudioFilterConfig"}, "type": "object"}, {"type": "null"}], "default": null, "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.", "title": "Audio filters"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether audio detection was originally enabled in the static config file.", "title": "Original audio state"}, "num_threads": {"default": 2, "description": "Number of threads to use for audio detection processing.", "minimum": 1, "title": "Detection threads", "type": "integer"}}, "title": "AudioConfig", "type": "object"}, "AudioFilterConfig": {"additionalProperties": false, "properties": {"threshold": {"default": 0.8, "description": "Minimum confidence threshold for the audio event to be counted.", "exclusiveMaximum": 1.0, "minimum": 0.5, "title": "Minimum audio confidence", "type": "number"}}, "title": "AudioFilterConfig", "type": "object"}, "AudioTranscriptionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable automatic audio transcription for all cameras; can be overridden per-camera.", "title": "Enable audio transcription", "type": "boolean"}, "language": {"default": "en", "description": "Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.", "title": "Transcription language", "type": "string"}, "device": {"$ref": "#/$defs/EnrichmentsDeviceEnum", "default": "CPU", "description": "Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription.", "title": "Transcription device"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size to use for offline audio event transcription.", "title": "Model size"}, "live_enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Enable streaming live transcription for audio as it is received.", "title": "Live transcription"}}, "title": "AudioTranscriptionConfig", "type": "object"}, "AuthConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable native authentication for the Frigate UI.", "title": "Enable authentication", "type": "boolean"}, "reset_admin_password": {"default": false, "description": "If true, reset the admin user's password on startup and print the new password in logs.", "title": "Reset admin password", "type": "boolean"}, "cookie_name": {"default": "frigate_token", "description": "Name of the cookie used to store the JWT token for native authentication.", "pattern": "^[a-z_]+$", "title": "JWT cookie name", "type": "string"}, "cookie_secure": {"default": false, "description": "Set the secure flag on the auth cookie; should be true when using TLS.", "title": "Secure cookie flag", "type": "boolean"}, "session_length": {"default": 86400, "description": "Session duration in seconds for JWT-based sessions.", "minimum": 60, "title": "Session length", "type": "integer"}, "refresh_time": {"default": 1800, "description": "When a session is within this many seconds of expiring, refresh it back to full length.", "minimum": 30, "title": "Session refresh window", "type": "integer"}, "failed_login_rate_limit": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Rate limiting rules for failed login attempts to reduce brute-force attacks.", "title": "Failed login limits"}, "trusted_proxies": {"default": [], "description": "List of trusted proxy IPs used when determining client IP for rate limiting.", "items": {"type": "string"}, "title": "Trusted proxies", "type": "array"}, "hash_iterations": {"default": 600000, "description": "Number of PBKDF2-SHA256 iterations to use when hashing user passwords.", "title": "Hash iterations", "type": "integer"}, "roles": {"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "description": "Map roles to camera lists. An empty list grants access to all cameras for the role.", "title": "Role mappings", "type": "object"}, "admin_first_time_login": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "When true the UI may show a help link on the login page informing users how to sign in after an admin password reset. ", "title": "First-time admin flag"}}, "title": "AuthConfig", "type": "object"}, "BirdClassificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable bird classification.", "title": "Bird classification", "type": "boolean"}, "threshold": {"default": 0.9, "description": "Minimum classification score required to accept a bird classification.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Minimum score", "type": "number"}}, "title": "BirdClassificationConfig", "type": "object"}, "BirdseyeCameraConfig": {"properties": {"enabled": {"default": true, "description": "Enable or disable the Birdseye view feature.", "title": "Enable Birdseye", "type": "boolean"}, "modes": {"description": "Activity types that include cameras in Birdseye.", "items": {"$ref": "#/$defs/BirdseyeModeEnum"}, "title": "Activity types", "type": "array"}, "order": {"default": 0, "description": "Numeric position controlling the camera's ordering in the Birdseye layout.", "title": "Position", "type": "integer"}}, "title": "BirdseyeCameraConfig", "type": "object"}, "BirdseyeConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable the Birdseye view feature.", "title": "Enable Birdseye", "type": "boolean"}, "modes": {"description": "Activity types that include cameras in Birdseye.", "items": {"$ref": "#/$defs/BirdseyeModeEnum"}, "title": "Activity types", "type": "array"}, "restream": {"default": false, "description": "Re-stream the Birdseye output as an RTSP feed; enabling this will keep Birdseye running continuously.", "title": "Restream RTSP", "type": "boolean"}, "width": {"default": 1280, "description": "Output width (pixels) of the composed Birdseye frame.", "title": "Width", "type": "integer"}, "height": {"default": 720, "description": "Output height (pixels) of the composed Birdseye frame.", "title": "Height", "type": "integer"}, "quality": {"default": 8, "description": "Encoding quality for the Birdseye mpeg1 feed (1 highest quality, 31 lowest).", "maximum": 31, "minimum": 1, "title": "Encoding quality", "type": "integer"}, "inactivity_threshold": {"default": 30, "description": "Seconds of inactivity after which a camera will stop being shown in Birdseye.", "exclusiveMinimum": 0, "title": "Inactivity threshold", "type": "integer"}, "layout": {"$ref": "#/$defs/BirdseyeLayoutConfig", "description": "Layout options for the Birdseye composition.", "title": "Layout"}, "idle_heartbeat_fps": {"default": 0.0, "description": "Frames-per-second to resend the last composed Birdseye frame when idle; set to 0 to disable.", "maximum": 10.0, "minimum": 0.0, "title": "Idle heartbeat FPS", "type": "number"}}, "title": "BirdseyeConfig", "type": "object"}, "BirdseyeLayoutConfig": {"additionalProperties": false, "properties": {"scaling_factor": {"default": 2.0, "description": "Scaling factor used by the layout calculator (range 1.0 to 5.0).", "maximum": 5.0, "minimum": 1.0, "title": "Scaling factor", "type": "number"}, "max_cameras": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Maximum number of cameras to display at once in Birdseye; shows the most recent cameras.", "title": "Max cameras"}}, "title": "BirdseyeLayoutConfig", "type": "object"}, "BirdseyeModeEnum": {"enum": ["continuous", "motion", "all_objects", "alerts", "detections"], "title": "BirdseyeModeEnum", "type": "string"}, "CameraAudioTranscriptionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable manually triggered audio event transcription.", "title": "Enable transcription", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Original transcription state"}, "live_enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Enable streaming live transcription for audio as it is received.", "title": "Live transcription"}}, "title": "CameraAudioTranscriptionConfig", "type": "object"}, "CameraConfig": {"additionalProperties": false, "properties": {"name": {"anyOf": [{"pattern": "^[a-zA-Z0-9_-]+$", "type": "string"}, {"type": "null"}], "default": null, "description": "Camera name is required", "title": "Camera name"}, "friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Camera friendly name used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enabled", "title": "Enabled", "type": "boolean"}, "audio": {"$ref": "#/$defs/AudioConfig", "description": "Settings for audio-based event detection for this camera.", "title": "Audio detection"}, "audio_transcription": {"$ref": "#/$defs/CameraAudioTranscriptionConfig", "description": "Settings for live and speech audio transcription used for events and live captions.", "title": "Audio transcription"}, "birdseye": {"$ref": "#/$defs/BirdseyeCameraConfig", "description": "Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", "title": "Birdseye"}, "detect": {"$ref": "#/$defs/DetectConfig", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "title": "Object Detection"}, "face_recognition": {"$ref": "#/$defs/CameraFaceRecognitionConfig", "description": "Settings for face detection and recognition for this camera.", "title": "Face recognition"}, "ffmpeg": {"$ref": "#/$defs/CameraFfmpegConfig", "description": "Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.", "title": "Streams (FFmpeg)"}, "live": {"$ref": "#/$defs/CameraLiveConfig", "description": "Settings used by the Web UI to control live stream selection, resolution and quality.", "title": "Live playback"}, "lpr": {"$ref": "#/$defs/CameraLicensePlateRecognitionConfig", "description": "License plate recognition settings including detection thresholds, formatting, and known plates.", "title": "License Plate Recognition"}, "motion": {"$ref": "#/$defs/MotionConfig", "default": null, "description": "Default motion detection settings for this camera.", "title": "Motion detection"}, "objects": {"$ref": "#/$defs/ObjectConfig", "description": "Object tracking defaults including which labels to track and per-object filters.", "title": "Objects"}, "record": {"$ref": "#/$defs/RecordConfig", "description": "Recording and retention settings for this camera.", "title": "Recording"}, "review": {"$ref": "#/$defs/ReviewConfig", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.", "title": "Review"}, "semantic_search": {"$ref": "#/$defs/CameraSemanticSearchConfig", "description": "Settings for semantic search which builds and queries object embeddings to find similar items.", "title": "Semantic Search"}, "snapshots": {"$ref": "#/$defs/SnapshotsConfig", "description": "Settings for API-generated snapshots of tracked objects for this camera.", "title": "Snapshots"}, "timestamp_style": {"$ref": "#/$defs/TimestampStyleConfig", "description": "Styling options for timestamps applied to snapshots and Debug view.", "title": "Timestamp style"}, "best_image_timeout": {"default": 60, "description": "How long to wait for the image with the highest confidence score.", "title": "Best image timeout", "type": "integer"}, "mqtt": {"$ref": "#/$defs/CameraMqttConfig", "description": "MQTT image publishing settings.", "title": "MQTT"}, "notifications": {"$ref": "#/$defs/NotificationConfig", "description": "Settings to enable and control notifications for this camera.", "title": "Notifications"}, "onvif": {"$ref": "#/$defs/OnvifConfig", "description": "ONVIF connection and PTZ autotracking settings for this camera.", "title": "ONVIF"}, "type": {"$ref": "#/$defs/CameraTypeEnum", "default": "generic", "description": "Camera Type", "title": "Camera type"}, "ui": {"$ref": "#/$defs/CameraUiConfig", "description": "Display ordering and visibility for this camera in the UI. Ordering affects the default dashboard. For more granular control, use camera groups.", "title": "Camera UI"}, "webui_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "URL to visit the camera directly from system page", "title": "Camera URL"}, "profiles": {"additionalProperties": {"$ref": "#/$defs/CameraProfileConfig"}, "description": "Named config profiles with partial overrides that can be activated at runtime.", "title": "Profiles", "type": "object"}, "zones": {"additionalProperties": {"$ref": "#/$defs/ZoneConfig"}, "description": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.", "title": "Zones", "type": "object"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Keep track of original state of camera.", "title": "Original camera state"}}, "required": ["ffmpeg"], "title": "CameraConfig", "type": "object"}, "CameraFaceRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable face recognition.", "title": "Enable face recognition", "type": "boolean"}, "min_area": {"default": 750, "description": "Minimum area (pixels) of a detected face box required to attempt recognition.", "title": "Minimum face area", "type": "integer"}}, "title": "CameraFaceRecognitionConfig", "type": "object"}, "CameraFfmpegConfig": {"additionalProperties": false, "properties": {"path": {"default": "default", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\").", "title": "FFmpeg path", "type": "string"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "description": "Global arguments passed to FFmpeg processes.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-rtsp-generic", "description": "Input arguments applied to FFmpeg input streams.", "title": "Input arguments"}, "output_args": {"$ref": "#/$defs/FfmpegOutputArgsConfig", "description": "Default output arguments used for different FFmpeg roles such as detect and record.", "title": "Output arguments"}, "retry_interval": {"default": 10.0, "description": "Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", "exclusiveMinimum": 0.0, "title": "FFmpeg retry time", "type": "number"}, "apple_compatibility": {"default": false, "description": "Enable HEVC tagging for better Apple player compatibility when recording H.265.", "title": "Apple compatibility", "type": "boolean"}, "gpu": {"default": 0, "description": "Default GPU index used for hardware acceleration if available.", "title": "GPU index", "type": "integer"}, "inputs": {"description": "List of input stream definitions (paths and roles) for this camera.", "items": {"$ref": "#/$defs/CameraInput"}, "title": "Camera inputs", "type": "array"}}, "required": ["inputs"], "title": "CameraFfmpegConfig", "type": "object"}, "CameraGroupConfig": {"additionalProperties": false, "properties": {"cameras": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Array of camera names included in this group.", "title": "Camera list"}, "icon": {"default": "generic", "description": "Icon used to represent the camera group in the UI.", "title": "Group icon", "type": "string"}, "order": {"default": 0, "description": "Numeric order used to sort camera groups in the UI; larger numbers appear later.", "title": "Sort order", "type": "integer"}}, "title": "CameraGroupConfig", "type": "object"}, "CameraInput": {"additionalProperties": false, "properties": {"path": {"description": "Camera input stream URL or path.", "title": "Input path", "type": "string"}, "roles": {"description": "Roles for this input stream.", "items": {"$ref": "#/$defs/CameraRoleEnum"}, "title": "Input roles", "type": "array"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "FFmpeg global arguments for this input stream.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Hardware acceleration arguments for this input stream.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Input arguments specific to this stream.", "title": "Input arguments"}}, "required": ["path", "roles"], "title": "CameraInput", "type": "object"}, "CameraLicensePlateRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable LPR on this camera.", "title": "Enable LPR", "type": "boolean"}, "expire_time": {"default": 3, "description": "Time in seconds after which an unseen plate is expired from the tracker (for dedicated LPR cameras only).", "exclusiveMinimum": 0, "title": "Expire seconds", "type": "integer"}, "min_area": {"default": 1000, "description": "Minimum plate area (pixels) required to attempt recognition.", "title": "Minimum plate area", "type": "integer"}, "enhancement": {"default": 0, "description": "Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", "maximum": 10, "minimum": 0, "title": "Enhancement level", "type": "integer"}}, "title": "CameraLicensePlateRecognitionConfig", "type": "object"}, "CameraLiveConfig": {"additionalProperties": false, "properties": {"streams": {"additionalProperties": {"type": "string"}, "description": "Mapping of configured stream names to restream/go2rtc names used for live playback.", "title": "Live stream names", "type": "object"}, "height": {"default": 720, "description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height.", "title": "Live height", "type": "integer"}, "quality": {"default": 8, "description": "Encoding quality for the jsmpeg stream (1 highest, 31 lowest).", "maximum": 31, "minimum": 1, "title": "Live quality", "type": "integer"}}, "title": "CameraLiveConfig", "type": "object"}, "CameraMqttConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable publishing image snapshots for objects to MQTT topics for this camera.", "title": "Send image", "type": "boolean"}, "timestamp": {"default": true, "description": "Overlay a timestamp on images published to MQTT.", "title": "Add timestamp", "type": "boolean"}, "bounding_box": {"default": true, "description": "Draw bounding boxes on images published over MQTT.", "title": "Add bounding box", "type": "boolean"}, "crop": {"default": true, "description": "Crop images published to MQTT to the detected object's bounding box.", "title": "Crop image", "type": "boolean"}, "height": {"default": 270, "description": "Height (pixels) to resize images published over MQTT.", "title": "Image height", "type": "integer"}, "required_zones": {"description": "Zones that an object must enter for an MQTT image to be published.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "quality": {"default": 70, "description": "JPEG quality for images published to MQTT (0-100).", "maximum": 100, "minimum": 0, "title": "JPEG quality", "type": "integer"}}, "title": "CameraMqttConfig", "type": "object"}, "CameraProfileConfig": {"additionalProperties": false, "description": "A named profile containing partial camera config overrides.\n\nSections set to None inherit from the camera's base config.\nSections that are defined get Pydantic-validated, then only\nexplicitly-set fields are used as overrides via exclude_unset.", "properties": {"enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Enabled"}, "audio": {"anyOf": [{"$ref": "#/$defs/AudioConfig"}, {"type": "null"}], "default": null}, "birdseye": {"anyOf": [{"$ref": "#/$defs/BirdseyeCameraConfig"}, {"type": "null"}], "default": null}, "detect": {"anyOf": [{"$ref": "#/$defs/DetectConfig"}, {"type": "null"}], "default": null}, "face_recognition": {"anyOf": [{"$ref": "#/$defs/CameraFaceRecognitionConfig"}, {"type": "null"}], "default": null}, "lpr": {"anyOf": [{"$ref": "#/$defs/CameraLicensePlateRecognitionConfig"}, {"type": "null"}], "default": null}, "motion": {"anyOf": [{"$ref": "#/$defs/MotionConfig"}, {"type": "null"}], "default": null}, "notifications": {"anyOf": [{"$ref": "#/$defs/NotificationConfig"}, {"type": "null"}], "default": null}, "objects": {"anyOf": [{"$ref": "#/$defs/ObjectConfig"}, {"type": "null"}], "default": null}, "record": {"anyOf": [{"$ref": "#/$defs/RecordConfig"}, {"type": "null"}], "default": null}, "review": {"anyOf": [{"$ref": "#/$defs/ReviewConfig"}, {"type": "null"}], "default": null}, "snapshots": {"anyOf": [{"$ref": "#/$defs/SnapshotsConfig"}, {"type": "null"}], "default": null}, "zones": {"anyOf": [{"additionalProperties": {"$ref": "#/$defs/ZoneConfig"}, "type": "object"}, {"type": "null"}], "default": null, "title": "Zones"}}, "title": "CameraProfileConfig", "type": "object"}, "CameraRoleEnum": {"enum": ["audio", "record", "record_sub", "detect"], "title": "CameraRoleEnum", "type": "string"}, "CameraSemanticSearchConfig": {"additionalProperties": false, "properties": {"triggers": {"additionalProperties": {"$ref": "#/$defs/TriggerConfig"}, "default": {}, "description": "Actions and matching criteria for camera-specific semantic search triggers.", "title": "Triggers", "type": "object"}}, "title": "CameraSemanticSearchConfig", "type": "object"}, "CameraTypeEnum": {"enum": ["generic", "lpr"], "title": "CameraTypeEnum", "type": "string"}, "CameraUiConfig": {"additionalProperties": false, "properties": {"order": {"default": 0, "description": "Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later.", "title": "UI order", "type": "integer"}, "dashboard": {"default": true, "description": "Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings.", "title": "Show on Live dashboard", "type": "boolean"}, "review": {"default": true, "description": "Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view).", "title": "Show in review", "type": "boolean"}}, "title": "CameraUiConfig", "type": "object"}, "ChaptersEnum": {"enum": ["none", "recording_segments", "review_items"], "title": "ChaptersEnum", "type": "string"}, "ClassificationConfig": {"additionalProperties": false, "properties": {"bird": {"$ref": "#/$defs/BirdClassificationConfig", "description": "Settings specific to bird classification models.", "title": "Bird classification config"}, "custom": {"additionalProperties": {"$ref": "#/$defs/CustomClassificationConfig"}, "default": {}, "description": "Configuration for custom classification models used for objects or state detection.", "title": "Custom Classification Models", "type": "object"}}, "title": "ClassificationConfig", "type": "object"}, "ColorConfig": {"additionalProperties": false, "properties": {"red": {"default": 255, "description": "Red component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Red", "type": "integer"}, "green": {"default": 255, "description": "Green component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Green", "type": "integer"}, "blue": {"default": 255, "description": "Blue component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Blue", "type": "integer"}}, "title": "ColorConfig", "type": "object"}, "CustomClassificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable the custom classification model.", "title": "Enable model", "type": "boolean"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Identifier for the custom classification model to use.", "title": "Model name"}, "threshold": {"default": 0.8, "description": "Score threshold used to change the classification state.", "title": "Score threshold", "type": "number"}, "save_attempts": {"anyOf": [{"minimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "How many classification attempts to save for recent classifications UI.", "title": "Save attempts"}, "object_config": {"anyOf": [{"$ref": "#/$defs/CustomClassificationObjectConfig"}, {"type": "null"}], "default": null}, "state_config": {"anyOf": [{"$ref": "#/$defs/CustomClassificationStateConfig"}, {"type": "null"}], "default": null}}, "title": "CustomClassificationConfig", "type": "object"}, "CustomClassificationObjectConfig": {"additionalProperties": false, "properties": {"objects": {"description": "List of object types to run object classification on.", "items": {"type": "string"}, "title": "Classify objects", "type": "array"}, "classification_type": {"$ref": "#/$defs/ObjectClassificationType", "default": "sub_label", "description": "Classification type applied: 'sub_label' (adds sub_label) or other supported types.", "title": "Classification type"}}, "title": "CustomClassificationObjectConfig", "type": "object"}, "CustomClassificationStateCameraConfig": {"additionalProperties": false, "properties": {"crop": {"description": "Crop coordinates to use for running classification on this camera.", "items": {"type": "number"}, "title": "Classification crop", "type": "array"}}, "required": ["crop"], "title": "CustomClassificationStateCameraConfig", "type": "object"}, "CustomClassificationStateConfig": {"additionalProperties": false, "properties": {"cameras": {"additionalProperties": {"$ref": "#/$defs/CustomClassificationStateCameraConfig"}, "description": "Per-camera crop and settings for running state classification.", "title": "Classification cameras", "type": "object"}, "motion": {"default": false, "description": "If true, run classification when motion is detected within the specified crop.", "title": "Run on motion", "type": "boolean"}, "interval": {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "Interval (seconds) between periodic classification runs for state classification.", "title": "Classification interval"}}, "required": ["cameras"], "title": "CustomClassificationStateConfig", "type": "object"}, "DatabaseConfig": {"additionalProperties": false, "properties": {"path": {"default": "/config/frigate.db", "description": "Filesystem path where the Frigate SQLite database file will be stored.", "title": "Database path", "type": "string"}}, "title": "DatabaseConfig", "type": "object"}, "DetectConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable object detection for all cameras; can be overridden per-camera.", "title": "Enable object detection", "type": "boolean"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Height (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", "title": "Detect height"}, "width": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", "title": "Detect width"}, "scene": {"$ref": "#/$defs/SceneEnum", "default": "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'.", "title": "Detect scene"}, "fps": {"default": 5, "description": "Desired frames per second to run detection on; lower values reduce CPU usage (recommended value is 5, only set higher - at most 10 - if tracking extremely fast moving objects).", "title": "Detect FPS", "type": "integer"}, "min_initialized": {"anyOf": [{"minimum": 2, "type": "integer"}, {"type": "null"}], "default": null, "description": "Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.", "title": "Minimum initialization frames"}, "max_disappeared": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Number of frames without a detection before a tracked object is considered gone.", "title": "Maximum disappeared frames"}, "stationary": {"$ref": "#/$defs/StationaryConfig", "description": "Settings to detect and manage objects that remain stationary for a period of time.", "title": "Stationary objects config"}, "annotation_offset": {"default": 0, "description": "Milliseconds to shift detect annotations to better align timeline bounding boxes with recordings; can be positive or negative.", "title": "Annotation offset", "type": "integer"}}, "title": "DetectConfig", "type": "object"}, "DetectionsConfig": {"additionalProperties": false, "description": "Configure detections", "properties": {"enabled": {"default": true, "description": "Enable or disable detection events for all cameras; can be overridden per-camera.", "title": "Enable detections", "type": "boolean"}, "labels": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null, "description": "List of object labels that qualify as detection events.", "title": "Detection labels"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that an object must enter to be considered a detection; leave empty to allow any zone.", "title": "Required zones"}, "cutoff_time": {"default": 30, "description": "Seconds to wait after no detection-causing activity before cutting off a detection.", "title": "Detections cutoff time", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether detections were originally enabled in the static configuration.", "title": "Original detections state"}}, "title": "DetectionsConfig", "type": "object"}, "EnrichmentsDeviceEnum": {"enum": ["GPU", "CPU"], "title": "EnrichmentsDeviceEnum", "type": "string"}, "EventsConfig": {"additionalProperties": false, "properties": {"pre_capture": {"default": 5, "description": "Number of seconds before the detection event to include in the recording.", "maximum": 60, "minimum": 0, "title": "Pre-capture seconds", "type": "integer"}, "post_capture": {"default": 5, "description": "Number of seconds after the detection event to include in the recording.", "minimum": 0, "title": "Post-capture seconds", "type": "integer"}, "retain": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for recordings of detection events.", "title": "Event retention"}}, "title": "EventsConfig", "type": "object"}, "FaceRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable face recognition for all cameras; can be overridden per-camera.", "title": "Enable face recognition", "type": "boolean"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size to use for face embeddings (small/large); larger may require GPU.", "title": "Model size"}, "unknown_score": {"default": 0.8, "description": "Distance threshold below which a face is considered a potential match (higher = stricter).", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Unknown score threshold", "type": "number"}, "detection_threshold": {"default": 0.7, "description": "Minimum detection confidence required to consider a face detection valid.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Detection threshold", "type": "number"}, "recognition_threshold": {"default": 0.9, "description": "Face embedding distance threshold to consider two faces a match.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Recognition threshold", "type": "number"}, "min_area": {"default": 750, "description": "Minimum area (pixels) of a detected face box required to attempt recognition.", "title": "Minimum face area", "type": "integer"}, "min_faces": {"default": 1, "description": "Minimum number of face recognitions required before applying a recognized sub-label to a person.", "exclusiveMinimum": 0, "maximum": 6, "title": "Minimum faces", "type": "integer"}, "save_attempts": {"default": 200, "description": "Number of face recognition attempts to retain for recent recognition UI.", "minimum": 0, "title": "Save attempts", "type": "integer"}, "blur_confidence_filter": {"default": true, "description": "Adjust confidence scores based on image blur to reduce false positives for poor quality faces.", "title": "Blur confidence filter", "type": "boolean"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}}, "title": "FaceRecognitionConfig", "type": "object"}, "FfmpegConfig": {"additionalProperties": false, "properties": {"path": {"default": "default", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\").", "title": "FFmpeg path", "type": "string"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "description": "Global arguments passed to FFmpeg processes.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-rtsp-generic", "description": "Input arguments applied to FFmpeg input streams.", "title": "Input arguments"}, "output_args": {"$ref": "#/$defs/FfmpegOutputArgsConfig", "description": "Default output arguments used for different FFmpeg roles such as detect and record.", "title": "Output arguments"}, "retry_interval": {"default": 10.0, "description": "Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", "exclusiveMinimum": 0.0, "title": "FFmpeg retry time", "type": "number"}, "apple_compatibility": {"default": false, "description": "Enable HEVC tagging for better Apple player compatibility when recording H.265.", "title": "Apple compatibility", "type": "boolean"}, "gpu": {"default": 0, "description": "Default GPU index used for hardware acceleration if available.", "title": "GPU index", "type": "integer"}}, "title": "FfmpegConfig", "type": "object"}, "FfmpegOutputArgsConfig": {"additionalProperties": false, "properties": {"detect": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "description": "Default output arguments for detect role streams.", "title": "Detect output arguments"}, "record": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-record-generic-audio-aac", "description": "Default output arguments for record role streams.", "title": "Record output arguments"}, "record_sub": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Output arguments for record_sub role streams. The record output arguments are used when this is not set.", "title": "Sub stream record output arguments"}}, "title": "FfmpegOutputArgsConfig", "type": "object"}, "FilterConfig": {"additionalProperties": false, "properties": {"min_area": {"anyOf": [{"type": "integer"}, {"type": "number"}], "default": 0, "description": "Minimum bounding box area (pixels or percentage) required for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", "title": "Minimum object area"}, "max_area": {"anyOf": [{"type": "integer"}, {"type": "number"}], "default": 24000000, "description": "Maximum bounding box area (pixels or percentage) allowed for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", "title": "Maximum object area"}, "min_ratio": {"default": 0, "description": "Minimum width/height ratio required for the bounding box to qualify.", "title": "Minimum aspect ratio", "type": "number"}, "max_ratio": {"default": 24000000, "description": "Maximum width/height ratio allowed for the bounding box to qualify.", "title": "Maximum aspect ratio", "type": "number"}, "threshold": {"default": 0.7, "description": "Average detection confidence threshold required for the object to be considered a true positive.", "title": "Confidence threshold", "type": "number"}, "min_score": {"default": 0.5, "description": "Minimum single-frame detection confidence required for the object to be counted.", "title": "Minimum confidence", "type": "number"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "description": "Polygon coordinates defining where this filter applies within the frame.", "title": "Filter mask", "type": "object"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}}, "title": "FilterConfig", "type": "object"}, "GenAIConfig": {"additionalProperties": false, "description": "Primary GenAI Config to define GenAI Provider.", "properties": {"api_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "API key required by some providers (can also be set via environment variables).", "title": "API key"}, "base_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Base URL for self-hosted or compatible providers (for example an Ollama instance).", "title": "Base URL"}, "model": {"default": "", "description": "The model to use from the provider for generating descriptions or summaries.", "title": "Model", "type": "string"}, "provider": {"$ref": "#/$defs/GenAIProviderEnum", "description": "The GenAI provider to use (for example: ollama, gemini, openai).", "title": "Provider"}, "roles": {"description": "GenAI roles (chat, descriptions, embeddings); one provider per role.", "items": {"$ref": "#/$defs/GenAIRoleEnum"}, "title": "Roles", "type": "array"}, "provider_options": {"additionalProperties": {}, "default": {}, "description": "Additional provider-specific options to pass to the GenAI client.", "title": "Provider options", "type": "object"}, "runtime_options": {"additionalProperties": {}, "default": {}, "description": "Runtime options passed to the provider for each inference call.", "title": "Runtime options", "type": "object"}}, "required": ["provider"], "title": "GenAIConfig", "type": "object"}, "GenAIObjectConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable GenAI generation of descriptions for tracked objects by default.", "title": "Enable GenAI", "type": "boolean"}, "use_snapshot": {"default": false, "description": "Use object snapshots instead of thumbnails for GenAI description generation.", "title": "Use snapshots", "type": "boolean"}, "prompt": {"default": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "description": "Default prompt template used when generating descriptions with GenAI.", "title": "Caption prompt", "type": "string"}, "object_prompts": {"additionalProperties": {"type": "string"}, "description": "Per-object prompts to customize GenAI outputs for specific labels.", "title": "Object prompts", "type": "object"}, "objects": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "List of object labels to send to GenAI by default.", "title": "GenAI objects"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that must be entered for objects to qualify for GenAI description generation.", "title": "Required zones"}, "debug_save_thumbnails": {"default": false, "description": "Save thumbnails sent to GenAI for debugging and review.", "title": "Save thumbnails", "type": "boolean"}, "send_triggers": {"$ref": "#/$defs/GenAIObjectTriggerConfig", "description": "Defines when frames should be sent to GenAI (on end, after updates, etc.).", "title": "GenAI triggers"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether GenAI was enabled in the original static config.", "title": "Original GenAI state"}}, "title": "GenAIObjectConfig", "type": "object"}, "GenAIObjectTriggerConfig": {"additionalProperties": false, "properties": {"tracked_object_end": {"default": true, "description": "Send a request to GenAI when the tracked object ends.", "title": "Send on end", "type": "boolean"}, "after_significant_updates": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "description": "Send a request to GenAI after a specified number of significant updates for the tracked object.", "title": "Early GenAI trigger"}}, "title": "GenAIObjectTriggerConfig", "type": "object"}, "GenAIProviderEnum": {"enum": ["openai", "azure_openai", "gemini", "ollama", "llamacpp"], "title": "GenAIProviderEnum", "type": "string"}, "GenAIReviewConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable GenAI-generated descriptions and summaries for review items.", "title": "Enable GenAI descriptions", "type": "boolean"}, "alerts": {"default": true, "description": "Use GenAI to generate descriptions for alert items.", "title": "Enable GenAI for alerts", "type": "boolean"}, "detections": {"default": false, "description": "Use GenAI to generate descriptions for detection items.", "title": "Enable GenAI for detections", "type": "boolean"}, "image_source": {"$ref": "#/$defs/ImageSourceEnum", "default": "preview", "description": "Source of images sent to GenAI ('preview' or 'recordings'); 'recordings' uses higher quality frames but more tokens.", "title": "Review image source"}, "additional_concerns": {"default": [], "description": "A list of additional concerns or notes the GenAI should consider when evaluating activity on this camera.", "items": {"type": "string"}, "title": "Additional concerns", "type": "array"}, "debug_save_thumbnails": {"default": false, "description": "Save thumbnails that are sent to the GenAI provider for debugging and review.", "title": "Save thumbnails", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether GenAI review was originally enabled in the static configuration.", "title": "Original GenAI state"}, "preferred_language": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Preferred language to request from the GenAI provider for generated responses.", "title": "Preferred language"}, "activity_context_prompt": {"default": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is.", "description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries.", "title": "Activity context prompt", "type": "string"}}, "title": "GenAIReviewConfig", "type": "object"}, "GenAIRoleEnum": {"enum": ["chat", "descriptions", "embeddings"], "title": "GenAIRoleEnum", "type": "string"}, "HeaderMappingConfig": {"additionalProperties": false, "properties": {"user": {"default": null, "description": "Header containing the authenticated username provided by the upstream proxy.", "title": "User header", "type": "string"}, "role": {"default": null, "description": "Header containing the authenticated user's role or groups from the upstream proxy.", "title": "Role header", "type": "string"}, "role_map": {"anyOf": [{"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "type": "object"}, {"type": "null"}], "description": "Map upstream group values to Frigate roles (for example map admin groups to the admin role).", "title": "Role mapping"}}, "title": "HeaderMappingConfig", "type": "object"}, "IPv6Config": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable IPv6 support for Frigate services (API and UI) where applicable.", "title": "Enable IPv6", "type": "boolean"}}, "title": "IPv6Config", "type": "object"}, "ImageSourceEnum": {"description": "Image source options for GenAI Review.", "enum": ["preview", "recordings"], "title": "ImageSourceEnum", "type": "string"}, "InputDTypeEnum": {"enum": ["float", "float_denorm", "int"], "title": "InputDTypeEnum", "type": "string"}, "InputTensorEnum": {"enum": ["nchw", "nhwc", "hwnc", "hwcn"], "title": "InputTensorEnum", "type": "string"}, "LicensePlateRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable license plate recognition for all cameras; can be overridden per-camera.", "title": "Enable LPR", "type": "boolean"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size used for text detection/recognition. Most users should use 'small'.", "title": "Model size"}, "detection_threshold": {"default": 0.7, "description": "Detection confidence threshold to begin running OCR on a suspected plate.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Detection threshold", "type": "number"}, "min_area": {"default": 1000, "description": "Minimum plate area (pixels) required to attempt recognition.", "title": "Minimum plate area", "type": "integer"}, "recognition_threshold": {"default": 0.9, "description": "Confidence threshold required for recognized plate text to be attached as a sub-label.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Recognition threshold", "type": "number"}, "min_plate_length": {"default": 4, "description": "Minimum number of characters a recognized plate must contain to be considered valid.", "title": "Min plate length", "type": "integer"}, "format": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional regex to validate recognized plate strings against an expected format.", "title": "Plate format regex"}, "match_distance": {"default": 1, "description": "Number of character mismatches allowed when comparing detected plates to known plates.", "minimum": 0, "title": "Match distance", "type": "integer"}, "known_plates": {"anyOf": [{"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "type": "object"}, {"type": "null"}], "default": {}, "description": "List of plates or regexes to specially track or alert on.", "title": "Known plates"}, "enhancement": {"default": 0, "description": "Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", "maximum": 10, "minimum": 0, "title": "Enhancement level", "type": "integer"}, "debug_save_plates": {"default": false, "description": "Save plate crop images for debugging LPR performance.", "title": "Save debug plates", "type": "boolean"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}, "replace_rules": {"description": "Regex replacement rules used to normalize detected plate strings before matching.", "items": {"$ref": "#/$defs/ReplaceRule"}, "title": "Replacement rules", "type": "array"}}, "title": "LicensePlateRecognitionConfig", "type": "object"}, "ListenConfig": {"additionalProperties": false, "properties": {"internal": {"anyOf": [{"type": "integer"}, {"type": "string"}], "default": 5000, "description": "Internal listening port for Frigate (default 5000).", "title": "Internal port"}, "external": {"anyOf": [{"type": "integer"}, {"type": "string"}], "default": 8971, "description": "External listening port for Frigate (default 8971).", "title": "External port"}}, "title": "ListenConfig", "type": "object"}, "LogLevel": {"enum": ["debug", "info", "warning", "error", "critical"], "title": "LogLevel", "type": "string"}, "LoggerConfig": {"additionalProperties": false, "properties": {"default": {"$ref": "#/$defs/LogLevel", "default": "info", "title": "Logging level", "description": "Default global log verbosity (debug, info, warning, error)."}, "logs": {"additionalProperties": {"$ref": "#/$defs/LogLevel"}, "description": "Per-component log level overrides to increase or decrease verbosity for specific modules.", "title": "Per-process log level", "type": "object"}}, "title": "LoggerConfig", "type": "object"}, "ModelConfig": {"additionalProperties": false, "properties": {"scene": {"$ref": "#/$defs/SceneEnum", "default": "all", "description": "The camera environment this model is used for. Cameras select a model by setting detect.scene to a matching value, and 'all' is used by any camera that does not set one.", "title": "Model scene"}, "devices": {"description": "Hardware this model runs on, as '' or ':' (for example 'edgetpu:pci:0' or 'openvino:GPU'). Listing the same device more than once runs additional inference processes on it.", "items": {"type": "string"}, "title": "Detection hardware", "type": "array"}, "path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to a custom detection model file (or plus:// for Frigate+ models).", "title": "Custom object detector model path"}, "labelmap_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to a labelmap file that maps numeric classes to string labels for the detector.", "title": "Label map for custom object detector"}, "width": {"default": 320, "description": "Width of the model input tensor in pixels.", "title": "Object detection model input width", "type": "integer"}, "height": {"default": 320, "description": "Height of the model input tensor in pixels.", "title": "Object detection model input height", "type": "integer"}, "labelmap": {"additionalProperties": {"type": "string"}, "description": "Overrides or remapping entries to merge into the standard labelmap.", "title": "Labelmap customization", "type": "object"}, "attributes_map": {"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "default": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate']).", "title": "Map of object labels to their attribute labels", "type": "object"}, "input_tensor": {"$ref": "#/$defs/InputTensorEnum", "default": "nhwc", "description": "Tensor format expected by the model: 'nhwc' or 'nchw'.", "title": "Model Input Tensor Shape"}, "input_pixel_format": {"$ref": "#/$defs/PixelFormatEnum", "default": "rgb", "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'.", "title": "Model Input Pixel Color Format"}, "input_dtype": {"$ref": "#/$defs/InputDTypeEnum", "default": "int", "description": "Data type of the model input tensor (for example 'float32').", "title": "Model Input D Type"}, "model_type": {"$ref": "#/$defs/ModelTypeEnum", "default": "ssd", "description": "Detector model architecture type (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine) used by some detectors for optimization.", "title": "Object Detection Model Type"}}, "title": "ModelConfig", "type": "object"}, "ModelSizeEnum": {"enum": ["small", "large"], "title": "ModelSizeEnum", "type": "string"}, "ModelTypeEnum": {"enum": ["dfine", "rfdetr", "ssd", "yolox", "yolonas", "yolo-generic"], "title": "ModelTypeEnum", "type": "string"}, "MotionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable motion detection for all cameras; can be overridden per-camera.", "title": "Enable motion detection", "type": "boolean"}, "threshold": {"default": 30, "description": "Pixel difference threshold used by the motion detector; higher values reduce sensitivity (range 1-255).", "maximum": 255, "minimum": 1, "title": "Motion threshold", "type": "integer"}, "lightning_threshold": {"default": 0.8, "description": "Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0). This does not prevent motion detection entirely; it merely causes the detector to stop analyzing additional frames once the threshold is exceeded. Motion-based recordings are still created during these events.", "maximum": 1.0, "minimum": 0.3, "title": "Lightning threshold", "type": "number"}, "skip_motion_threshold": {"anyOf": [{"maximum": 1.0, "minimum": 0.0, "type": "number"}, {"type": "null"}], "default": null, "description": "If set to a value between 0.0 and 1.0, and more than this fraction of the image changes in a single frame, the detector will return no motion boxes and immediately recalibrate. This can save CPU and reduce false positives during lightning, storms, etc., but may miss real events such as a PTZ camera auto\u2011tracking an object. The trade\u2011off is between dropping a few megabytes of recordings versus reviewing a couple short clips. Leave unset (None) to disable this feature.", "title": "Skip motion threshold"}, "improve_contrast": {"default": true, "description": "Apply contrast improvement to frames before motion analysis to help detection.", "title": "Improve contrast", "type": "boolean"}, "contour_area": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 10, "description": "Minimum contour area in pixels required for a motion contour to be counted.", "title": "Contour area"}, "delta_alpha": {"default": 0.2, "description": "Alpha blending factor used in frame differencing for motion calculation.", "title": "Delta alpha", "type": "number"}, "frame_alpha": {"default": 0.01, "description": "Alpha value used when blending frames for motion preprocessing.", "title": "Frame alpha", "type": "number"}, "frame_height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 100, "description": "Height in pixels to scale frames to when computing motion.", "title": "Frame height"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/MotionMaskConfig"}, {"type": "null"}]}, "description": "Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", "title": "Mask coordinates", "type": "object"}, "mqtt_off_delay": {"default": 30, "description": "Seconds to wait after last motion before publishing an MQTT 'off' state.", "title": "MQTT off delay", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether motion detection was enabled in the original static configuration.", "title": "Original motion state"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/MotionMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}}, "title": "MotionConfig", "type": "object"}, "MotionMaskConfig": {"additionalProperties": false, "description": "Configuration for a single motion mask.", "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A friendly name for this motion mask used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this motion mask", "title": "Enabled", "type": "boolean"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "description": "Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", "title": "Coordinates"}, "raw_coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "title": "Raw Coordinates"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of motion mask."}}, "title": "MotionMaskConfig", "type": "object"}, "MqttConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable MQTT integration for state, events, and snapshots.", "title": "Enable MQTT", "type": "boolean"}, "host": {"default": "", "description": "Hostname or IP address of the MQTT broker.", "title": "MQTT host", "type": "string"}, "port": {"default": 1883, "description": "Port of the MQTT broker (usually 1883 for plain MQTT).", "title": "MQTT port", "type": "integer"}, "topic_prefix": {"default": "frigate", "description": "MQTT topic prefix for all Frigate topics; must be unique if running multiple instances.", "title": "Topic prefix", "type": "string"}, "client_id": {"default": "frigate", "description": "Client identifier used when connecting to the MQTT broker; should be unique per instance.", "title": "Client ID", "type": "string"}, "stats_interval": {"default": 60, "description": "Interval in seconds for publishing system and camera stats to MQTT.", "minimum": 15, "title": "Stats interval", "type": "integer"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional MQTT username; can be provided via environment variables or secrets.", "title": "MQTT username"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional MQTT password; can be provided via environment variables or secrets.", "title": "MQTT password"}, "tls_ca_certs": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to CA certificate for TLS connections to the broker (for self-signed certs).", "title": "TLS CA certs"}, "tls_client_cert": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Client certificate path for TLS mutual authentication; do not set user/password when using client certs.", "title": "Client cert"}, "tls_client_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Private key path for the client certificate.", "title": "Client key"}, "tls_insecure": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Allow insecure TLS connections by skipping hostname verification (not recommended).", "title": "TLS insecure"}, "qos": {"default": 0, "description": "Quality of Service level for MQTT publishes/subscriptions (0, 1, or 2).", "title": "MQTT QoS", "type": "integer"}}, "title": "MqttConfig", "type": "object"}, "NetworkingConfig": {"additionalProperties": false, "properties": {"ipv6": {"$ref": "#/$defs/IPv6Config", "description": "IPv6-specific settings for Frigate network services.", "title": "IPv6 configuration"}, "listen": {"$ref": "#/$defs/ListenConfig", "description": "Configuration for internal and external listening ports. This is for advanced users. For the majority of use cases it's recommended to change the ports section of your Docker compose file.", "title": "Listening ports configuration"}}, "title": "NetworkingConfig", "type": "object"}, "NotificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable notifications for all cameras; can be overridden per-camera.", "title": "Enable notifications", "type": "boolean"}, "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Email address used for push notifications or required by certain notification providers.", "title": "Notification email"}, "cooldown": {"default": 0, "description": "Cooldown (seconds) between notifications to avoid spamming recipients.", "minimum": 0, "title": "Cooldown period", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether notifications were enabled in the original static configuration.", "title": "Original notifications state"}}, "title": "NotificationConfig", "type": "object"}, "ObjectClassificationType": {"enum": ["sub_label", "attribute"], "title": "ObjectClassificationType", "type": "string"}, "ObjectConfig": {"additionalProperties": false, "properties": {"track": {"default": ["person"], "description": "List of object labels to track for all cameras; can be overridden per-camera.", "items": {"type": "string"}, "title": "Objects to track", "type": "array"}, "filters": {"additionalProperties": {"$ref": "#/$defs/FilterConfig"}, "description": "Filters applied to detected objects to reduce false positives (area, ratio, confidence).", "title": "Object filters", "type": "object"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "description": "Mask polygon used to prevent object detection in specified areas.", "title": "Object mask", "type": "object"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}, "genai": {"$ref": "#/$defs/GenAIObjectConfig", "description": "GenAI options for describing tracked objects and sending frames for generation.", "title": "GenAI object config"}}, "title": "ObjectConfig", "type": "object"}, "ObjectMaskConfig": {"additionalProperties": false, "description": "Configuration for a single object mask.", "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A friendly name for this object mask used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this object mask", "title": "Enabled", "type": "boolean"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "description": "Ordered x,y coordinates defining the object mask polygon used to include/exclude areas.", "title": "Coordinates"}, "raw_coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "title": "Raw Coordinates"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of object mask."}}, "title": "ObjectMaskConfig", "type": "object"}, "OnvifConfig": {"additionalProperties": false, "properties": {"host": {"default": "", "description": "Host (and optional scheme) for the ONVIF service for this camera.", "title": "ONVIF host", "type": "string"}, "port": {"default": 8000, "description": "Port number for the ONVIF service.", "title": "ONVIF port", "type": "integer"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Username for ONVIF authentication; some devices require admin user for ONVIF.", "title": "ONVIF username"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Password for ONVIF authentication.", "title": "ONVIF password"}, "tls_insecure": {"default": false, "description": "Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only).", "title": "Disable TLS verify", "type": "boolean"}, "profile": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically.", "title": "ONVIF profile"}, "autotracking": {"$ref": "#/$defs/PtzAutotrackConfig", "description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.", "title": "Autotracking"}, "ignore_time_mismatch": {"default": false, "description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication.", "title": "Ignore time mismatch", "type": "boolean"}}, "title": "OnvifConfig", "type": "object"}, "PixelFormatEnum": {"enum": ["rgb", "bgr", "yuv"], "title": "PixelFormatEnum", "type": "string"}, "ProfileDefinitionConfig": {"additionalProperties": false, "description": "Defines a named profile with a human-readable display name.\n\nThe dict key is the machine name used internally; friendly_name\nis the label shown in the UI and API responses.", "properties": {"friendly_name": {"description": "Display name for this profile shown in the UI.", "title": "Friendly name", "type": "string"}}, "required": ["friendly_name"], "title": "ProfileDefinitionConfig", "type": "object"}, "ProxyConfig": {"additionalProperties": false, "properties": {"header_map": {"$ref": "#/$defs/HeaderMappingConfig", "description": "Map incoming proxy headers to Frigate user and role fields for proxy-based auth.", "title": "Header mapping"}, "logout_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "URL to redirect users to when logging out via the proxy.", "title": "Logout URL"}, "auth_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional secret checked against the X-Proxy-Secret header to verify trusted proxies.", "title": "Proxy secret"}, "default_role": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": "viewer", "description": "Default role assigned to proxy-authenticated users when no role mapping applies.", "title": "Default role"}, "separator": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": ",", "description": "Character used to split multiple values provided in proxy headers.", "title": "Separator character"}}, "title": "ProxyConfig", "type": "object"}, "PtzAutotrackConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable automatic PTZ camera tracking of detected objects.", "title": "Enable Autotracking", "type": "boolean"}, "calibrate_on_startup": {"default": false, "description": "Measure PTZ motor speeds on startup to improve tracking accuracy. Frigate will update config with movement_weights after calibration.", "title": "Calibrate on start", "type": "boolean"}, "zooming": {"$ref": "#/$defs/ZoomingModeEnum", "default": "disabled", "description": "Control zoom behavior: disabled (pan/tilt only), absolute (most compatible), or relative (concurrent pan/tilt/zoom).", "title": "Zoom mode"}, "zoom_factor": {"default": 0.3, "description": "Control zoom level on tracked objects. Lower values keep more scene in view; higher values zoom in closer but may lose tracking. Values between 0.1 and 0.75.", "maximum": 0.75, "minimum": 0.1, "title": "Zoom factor", "type": "number"}, "track": {"default": ["person"], "description": "List of object types that should trigger autotracking.", "items": {"type": "string"}, "title": "Tracked objects", "type": "array"}, "required_zones": {"description": "Objects must enter one of these zones before autotracking begins.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "return_preset": {"default": "home", "description": "ONVIF preset name configured in camera firmware to return to after tracking ends.", "title": "Return preset", "type": "string"}, "timeout": {"default": 10, "description": "Wait this many seconds after losing tracking before returning camera to preset position.", "title": "Return timeout", "type": "integer"}, "movement_weights": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "description": "Calibration values automatically generated by camera calibration. Do not modify manually.", "title": "Movement weights"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Internal field to track whether autotracking was enabled in configuration.", "title": "Original autotrack state"}}, "title": "PtzAutotrackConfig", "type": "object"}, "RecordConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable recording for all cameras; can be overridden per-camera.", "title": "Enable recording", "type": "boolean"}, "expire_interval": {"default": 60, "description": "Minutes between cleanup passes that remove expired recording segments.", "title": "Record cleanup interval", "type": "integer"}, "continuous": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain recordings regardless of tracked objects or motion. Set to 0 if you only want to retain recordings of alerts and detections.", "title": "Continuous retention"}, "motion": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain recordings triggered by motion regardless of tracked objects. Set to 0 if you only want to retain recordings of alerts and detections.", "title": "Motion retention"}, "detections": {"$ref": "#/$defs/EventsConfig", "description": "Recording retention settings for detection events including pre/post capture durations.", "title": "Detection retention"}, "alerts": {"$ref": "#/$defs/EventsConfig", "description": "Recording retention settings for alert events including pre/post capture durations.", "title": "Alert retention"}, "export": {"$ref": "#/$defs/RecordExportConfig", "description": "Settings used when exporting recordings such as timelapse and hardware acceleration.", "title": "Export config"}, "preview": {"$ref": "#/$defs/RecordPreviewConfig", "description": "Settings controlling the quality of recording previews shown in the UI.", "title": "Preview config"}, "sub": {"$ref": "#/$defs/RecordSubConfig", "description": "Settings for recording a second, lower quality stream.", "title": "Sub stream recording"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether recording was enabled in the original static configuration.", "title": "Original recording state"}}, "title": "RecordConfig", "type": "object"}, "RecordExportConfig": {"additionalProperties": false, "properties": {"hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration args to use for export/transcode operations.", "title": "Export hwaccel args"}, "max_concurrent": {"default": 3, "description": "Maximum number of export jobs to process at the same time.", "minimum": 1, "title": "Maximum concurrent exports", "type": "integer"}, "chapters": {"$ref": "#/$defs/ChaptersEnum", "default": "review_items", "title": "Chapter metadata to embed in exported recordings"}}, "title": "RecordExportConfig", "type": "object"}, "RecordPreviewConfig": {"additionalProperties": false, "properties": {"quality": {"$ref": "#/$defs/RecordQualityEnum", "default": "medium", "description": "Preview quality level (very_low, low, medium, high, very_high).", "title": "Preview quality"}}, "title": "RecordPreviewConfig", "type": "object"}, "RecordQualityEnum": {"enum": ["very_low", "low", "medium", "high", "very_high"], "title": "RecordQualityEnum", "type": "string"}, "RecordRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 0, "description": "Days to retain recordings.", "minimum": 0.0, "title": "Retention days", "type": "number"}}, "title": "RecordRetainConfig", "type": "object"}, "RecordSubConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable recording of a second, lower quality stream for adaptive quality playback and extended retention.", "title": "Enable sub stream recording", "type": "boolean"}, "continuous": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain sub stream recordings regardless of tracked objects or motion.", "title": "Sub stream continuous retention"}, "motion": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain sub stream recordings triggered by motion.", "title": "Sub stream motion retention"}, "alerts": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for sub stream recordings of alerts.", "title": "Sub stream alert retention"}, "detections": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for sub stream recordings of detections.", "title": "Sub stream detection retention"}}, "title": "RecordSubConfig", "type": "object"}, "ReplaceRule": {"additionalProperties": false, "properties": {"pattern": {"title": "Regex pattern", "type": "string"}, "replacement": {"title": "Replacement string", "type": "string"}}, "required": ["pattern", "replacement"], "title": "ReplaceRule", "type": "object"}, "RestreamConfig": {"additionalProperties": true, "properties": {}, "title": "RestreamConfig", "type": "object"}, "RetainConfig": {"additionalProperties": false, "properties": {"default": {"type": "number", "default": 10, "title": "Default retention", "description": "Default number of days to retain snapshots."}, "objects": {"additionalProperties": {"type": "number"}, "description": "Per-object overrides for snapshot retention days.", "title": "Object retention", "type": "object"}}, "title": "RetainConfig", "type": "object"}, "RetainModeEnum": {"enum": ["all", "motion", "active_objects"], "title": "RetainModeEnum", "type": "string"}, "ReviewConfig": {"additionalProperties": false, "properties": {"alerts": {"$ref": "#/$defs/AlertsConfig", "description": "Settings for which tracked objects generate alerts and how alerts are retained.", "title": "Alerts config"}, "detections": {"$ref": "#/$defs/DetectionsConfig", "description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.", "title": "Detections config"}, "genai": {"$ref": "#/$defs/GenAIReviewConfig", "description": "Controls use of generative AI for producing descriptions and summaries of review items.", "title": "GenAI config"}}, "title": "ReviewConfig", "type": "object"}, "ReviewRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 10, "description": "Number of days to retain recordings of detection events.", "minimum": 0.0, "title": "Retention days", "type": "number"}, "mode": {"$ref": "#/$defs/RetainModeEnum", "default": "motion", "description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).", "title": "Retention mode"}}, "title": "ReviewRetainConfig", "type": "object"}, "SceneEnum": {"description": "The camera environment a detection model is intended for.", "enum": ["all", "indoor", "outdoor", "indoor_thermal", "outdoor_thermal"], "title": "SceneEnum", "type": "string"}, "SemanticSearchConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable the semantic search feature.", "title": "Enable semantic search", "type": "boolean"}, "reindex": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Trigger a full reindex of historical tracked objects into the embeddings database.", "title": "Reindex on startup"}, "model": {"anyOf": [{"$ref": "#/$defs/SemanticSearchModelEnum"}, {"type": "string"}, {"type": "null"}], "default": "jinav1", "description": "The embeddings model to use for semantic search (for example 'jinav1'), or the name of a GenAI provider with the embeddings role.", "title": "Semantic search model or GenAI provider name"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Select model size; 'small' runs on CPU and 'large' typically requires GPU.", "title": "Model size"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}}, "title": "SemanticSearchConfig", "type": "object"}, "SemanticSearchModelEnum": {"enum": ["jinav1", "jinav2"], "title": "SemanticSearchModelEnum", "type": "string"}, "SnapshotsConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable saving snapshots for all cameras; can be overridden per-camera.", "title": "Enable snapshots", "type": "boolean"}, "timestamp": {"default": false, "description": "Overlay a timestamp on snapshots from API.", "title": "Timestamp overlay", "type": "boolean"}, "bounding_box": {"default": true, "description": "Draw bounding boxes for tracked objects on snapshots from API.", "title": "Bounding box overlay", "type": "boolean"}, "crop": {"default": false, "description": "Crop snapshots from API to the detected object's bounding box.", "title": "Crop snapshot", "type": "boolean"}, "required_zones": {"description": "Zones an object must enter for a snapshot to be saved.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Height (pixels) to resize snapshots from API to; leave empty to preserve original size.", "title": "Snapshot height"}, "retain": {"$ref": "#/$defs/RetainConfig", "description": "Retention settings for snapshots including default days and per-object overrides.", "title": "Snapshot retention"}, "quality": {"default": 60, "description": "Encode quality for saved snapshots (0-100).", "maximum": 100, "minimum": 0, "title": "Snapshot quality", "type": "integer"}}, "title": "SnapshotsConfig", "type": "object"}, "StationaryConfig": {"additionalProperties": false, "properties": {"interval": {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "How often (in frames) to run a detection check to confirm a stationary object.", "title": "Stationary interval"}, "threshold": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "description": "Number of frames with no position change required to mark an object as stationary.", "title": "Stationary threshold"}, "max_frames": {"$ref": "#/$defs/StationaryMaxFramesConfig", "description": "Limits how long stationary objects are tracked before being discarded.", "title": "Max frames"}, "classifier": {"default": true, "description": "Use a visual classifier to detect truly stationary objects even when bounding boxes jitter.", "title": "Enable visual classifier", "type": "boolean"}}, "title": "StationaryConfig", "type": "object"}, "StationaryMaxFramesConfig": {"additionalProperties": false, "properties": {"default": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "title": "Default max frames", "description": "Default maximum frames to track a stationary object before stopping."}, "objects": {"additionalProperties": {"type": "integer"}, "description": "Per-object overrides for maximum frames to track stationary objects.", "title": "Object max frames", "type": "object"}}, "title": "StationaryMaxFramesConfig", "type": "object"}, "StatsConfig": {"additionalProperties": false, "properties": {"amd_gpu_stats": {"default": true, "description": "Enable collection of AMD GPU statistics if an AMD GPU is present.", "title": "AMD GPU stats", "type": "boolean"}, "intel_gpu_stats": {"default": true, "description": "Enable collection of Intel GPU statistics if an Intel GPU is present.", "title": "Intel GPU stats", "type": "boolean"}, "network_bandwidth": {"default": false, "description": "Enable per-process network bandwidth monitoring for camera ffmpeg processes and detectors (requires capabilities).", "title": "Network bandwidth", "type": "boolean"}, "intel_gpu_device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present.", "title": "Intel GPU device"}}, "title": "StatsConfig", "type": "object"}, "TelemetryConfig": {"additionalProperties": false, "properties": {"network_interfaces": {"default": [], "description": "List of network interface name prefixes to monitor for bandwidth statistics.", "items": {"type": "string"}, "title": "Network interfaces", "type": "array"}, "stats": {"$ref": "#/$defs/StatsConfig", "description": "Options to enable/disable collection of various system and GPU statistics.", "title": "System stats"}, "version_check": {"default": true, "description": "Enable an outbound check to detect if a newer Frigate version is available.", "title": "Version check", "type": "boolean"}}, "title": "TelemetryConfig", "type": "object"}, "TimeFormatEnum": {"enum": ["browser", "12hour", "24hour"], "title": "TimeFormatEnum", "type": "string"}, "TimestampEffectEnum": {"enum": ["solid", "shadow"], "title": "TimestampEffectEnum", "type": "string"}, "TimestampPositionEnum": {"enum": ["tl", "tr", "bl", "br"], "title": "TimestampPositionEnum", "type": "string"}, "TimestampStyleConfig": {"additionalProperties": false, "properties": {"position": {"$ref": "#/$defs/TimestampPositionEnum", "default": "tl", "description": "Position of the timestamp on the image (tl/tr/bl/br).", "title": "Timestamp position"}, "format": {"default": "%m/%d/%Y %H:%M:%S", "description": "Datetime format string used for timestamps (Python datetime format codes).", "title": "Timestamp format", "type": "string"}, "color": {"$ref": "#/$defs/ColorConfig", "description": "RGB color values for the timestamp text (all values 0-255).", "title": "Timestamp color"}, "thickness": {"default": 2, "description": "Line thickness of the timestamp text.", "title": "Timestamp thickness", "type": "integer"}, "effect": {"anyOf": [{"$ref": "#/$defs/TimestampEffectEnum"}, {"type": "null"}], "default": null, "description": "Visual effect for the timestamp text (none, solid, shadow).", "title": "Timestamp effect"}}, "title": "TimestampStyleConfig", "type": "object"}, "TlsConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable TLS for Frigate's web UI and API on the configured TLS port.", "title": "Enable TLS", "type": "boolean"}}, "title": "TlsConfig", "type": "object"}, "TriggerAction": {"enum": ["notification", "sub_label", "attribute"], "title": "TriggerAction", "type": "string"}, "TriggerConfig": {"additionalProperties": false, "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional friendly name displayed in the UI for this trigger.", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this semantic search trigger.", "title": "Enable this trigger", "type": "boolean"}, "type": {"$ref": "#/$defs/TriggerType", "default": "description", "description": "Type of trigger: 'thumbnail' (match against image) or 'description' (match against text).", "title": "Trigger type"}, "data": {"description": "Text phrase or thumbnail ID to match against tracked objects.", "title": "Trigger content", "type": "string"}, "threshold": {"default": 0.8, "description": "Minimum similarity score (0-1) required to activate this trigger.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Trigger threshold", "type": "number"}, "actions": {"default": [], "description": "List of actions to execute when trigger matches (notification, sub_label, attribute).", "items": {"$ref": "#/$defs/TriggerAction"}, "title": "Trigger actions", "type": "array"}}, "required": ["data"], "title": "TriggerConfig", "type": "object"}, "TriggerType": {"enum": ["thumbnail", "description"], "title": "TriggerType", "type": "string"}, "UIConfig": {"additionalProperties": false, "properties": {"timezone": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional timezone to display across the UI (defaults to browser local time if unset).", "title": "Timezone"}, "time_format": {"$ref": "#/$defs/TimeFormatEnum", "default": "browser", "description": "Time format to use in the UI (browser, 12hour, or 24hour).", "title": "Time format"}, "unit_system": {"$ref": "#/$defs/UnitSystemEnum", "default": "metric", "description": "Unit system for display (metric or imperial) used in the UI and MQTT.", "title": "Unit system"}}, "title": "UIConfig", "type": "object"}, "UnitSystemEnum": {"enum": ["imperial", "metric"], "title": "UnitSystemEnum", "type": "string"}, "ZoneConfig": {"properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A user-friendly name for the zone, displayed in the Frigate UI. If not set, a formatted version of the zone name will be used.", "title": "Zone name"}, "enabled": {"default": true, "description": "Enable or disable this zone. Disabled zones are ignored at runtime.", "title": "Enabled", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of zone."}, "filters": {"additionalProperties": {"$ref": "#/$defs/FilterConfig"}, "description": "Filters to apply to objects within this zone. Used to reduce false positives or restrict which objects are considered present in the zone.", "title": "Zone filters", "type": "object"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Polygon coordinates that define the zone area. Can be a comma-separated string or a list of coordinate strings. Coordinates should be relative (0-1) or absolute (legacy).", "title": "Coordinates"}, "distances": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "description": "Optional real-world distances for each side of the zone quadrilateral, used for speed or distance calculations. Must have exactly 4 values if set.", "title": "Real-world distances"}, "inertia": {"default": 3, "description": "Number of consecutive frames an object must be detected in the zone before it is considered present. Helps filter out transient detections.", "exclusiveMinimum": 0, "title": "Inertia frames", "type": "integer"}, "loitering_time": {"default": 0, "description": "Number of seconds an object must remain in the zone to be considered as loitering. Set to 0 to disable loitering detection.", "minimum": 0, "title": "Loitering seconds", "type": "integer"}, "speed_threshold": {"anyOf": [{"minimum": 0.1, "type": "number"}, {"type": "null"}], "default": null, "description": "Minimum speed (in real-world units if distances are set) required for an object to be considered present in the zone. Used for speed-based zone triggers.", "title": "Minimum speed"}, "objects": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "List of object types (from labelmap) that can trigger this zone. Can be a string or a list of strings. If empty, all objects are considered.", "title": "Trigger objects"}}, "required": ["coordinates"], "title": "ZoneConfig", "type": "object"}, "ZoomingModeEnum": {"enum": ["disabled", "absolute", "relative"], "title": "ZoomingModeEnum", "type": "string"}}, "additionalProperties": false, "properties": {"version": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Numeric or string version of the active configuration to help detect migrations or format changes.", "title": "Current config version"}, "safe_mode": {"default": false, "description": "When enabled, start Frigate in safe mode with reduced features for troubleshooting.", "title": "Safe mode", "type": "boolean"}, "environment_vars": {"additionalProperties": {"type": "string"}, "description": "Key/value pairs of environment variables to set for the Frigate process in Home Assistant OS. Non-HAOS users must use Docker environment variable configuration instead.", "title": "Environment variables", "type": "object"}, "logger": {"$ref": "#/$defs/LoggerConfig", "description": "Controls default log verbosity and per-component log level overrides.", "title": "Logging"}, "auth": {"$ref": "#/$defs/AuthConfig", "description": "Authentication and session-related settings including cookie and rate limit options.", "title": "Authentication"}, "database": {"$ref": "#/$defs/DatabaseConfig", "description": "Settings for the SQLite database used by Frigate to store tracked object and recording metadata.", "title": "Database"}, "go2rtc": {"$ref": "#/$defs/RestreamConfig", "description": "Settings for the integrated go2rtc restreaming service used for live stream relaying and translation.", "title": "go2rtc"}, "mqtt": {"$ref": "#/$defs/MqttConfig", "description": "Settings for connecting and publishing telemetry, snapshots, and event details to an MQTT broker.", "title": "MQTT"}, "notifications": {"$ref": "#/$defs/NotificationConfig", "description": "Settings to enable and control notifications for all cameras; can be overridden per-camera.", "title": "Notifications"}, "networking": {"$ref": "#/$defs/NetworkingConfig", "description": "Network-related settings such as IPv6 enablement for Frigate endpoints.", "title": "Networking"}, "proxy": {"$ref": "#/$defs/ProxyConfig", "description": "Settings for integrating Frigate behind a reverse proxy that passes authenticated user headers.", "title": "Proxy"}, "telemetry": {"$ref": "#/$defs/TelemetryConfig", "description": "System telemetry and stats options including GPU and network bandwidth monitoring.", "title": "Telemetry"}, "tls": {"$ref": "#/$defs/TlsConfig", "description": "TLS settings for Frigate's web endpoints (port 8971).", "title": "TLS"}, "ui": {"$ref": "#/$defs/UIConfig", "description": "User interface preferences such as timezone, time/date formatting, and units.", "title": "UI"}, "models": {"description": "Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene.", "items": {"$ref": "#/$defs/ModelConfig"}, "title": "Detection models", "type": "array"}, "genai": {"additionalProperties": {"$ref": "#/$defs/GenAIConfig"}, "description": "Settings for integrated generative AI providers used to generate object descriptions and review summaries.", "title": "Generative AI configuration", "type": "object"}, "cameras": {"additionalProperties": {"$ref": "#/$defs/CameraConfig"}, "description": "Cameras", "title": "Cameras", "type": "object"}, "audio": {"$ref": "#/$defs/AudioConfig", "description": "Settings for audio-based event detection for all cameras; can be overridden per-camera.", "title": "Audio detection"}, "birdseye": {"$ref": "#/$defs/BirdseyeConfig", "description": "Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", "title": "Birdseye"}, "detect": {"$ref": "#/$defs/DetectConfig", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "title": "Object Detection"}, "ffmpeg": {"$ref": "#/$defs/FfmpegConfig", "description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", "title": "FFmpeg"}, "live": {"$ref": "#/$defs/CameraLiveConfig", "description": "Settings to control the jsmpeg live stream resolution and quality. This does not affect restreamed cameras that use go2rtc for live view.", "title": "Live playback"}, "motion": {"anyOf": [{"$ref": "#/$defs/MotionConfig"}, {"type": "null"}], "default": null, "description": "Default motion detection settings applied to cameras unless overridden per-camera.", "title": "Motion detection"}, "objects": {"$ref": "#/$defs/ObjectConfig", "description": "Object tracking defaults including which labels to track and per-object filters.", "title": "Objects"}, "record": {"$ref": "#/$defs/RecordConfig", "description": "Recording and retention settings applied to cameras unless overridden per-camera.", "title": "Recording"}, "review": {"$ref": "#/$defs/ReviewConfig", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage.", "title": "Review"}, "snapshots": {"$ref": "#/$defs/SnapshotsConfig", "description": "Settings for API-generated snapshots of tracked objects for all cameras; can be overridden per-camera.", "title": "Snapshots"}, "timestamp_style": {"$ref": "#/$defs/TimestampStyleConfig", "description": "Styling options for in-feed timestamps applied to debug view and snapshots.", "title": "Timestamp style"}, "audio_transcription": {"$ref": "#/$defs/AudioTranscriptionConfig", "description": "Settings for live and speech audio transcription used for events and live captions.", "title": "Audio transcription"}, "classification": {"$ref": "#/$defs/ClassificationConfig", "description": "Settings for classification models used to refine object labels or state classification.", "title": "Object classification"}, "semantic_search": {"$ref": "#/$defs/SemanticSearchConfig", "description": "Settings for Semantic Search which builds and queries object embeddings to find similar items.", "title": "Semantic Search"}, "face_recognition": {"$ref": "#/$defs/FaceRecognitionConfig", "description": "Settings for face detection and recognition for all cameras; can be overridden per-camera.", "title": "Face recognition"}, "lpr": {"$ref": "#/$defs/LicensePlateRecognitionConfig", "description": "License plate recognition settings including detection thresholds, formatting, and known plates.", "title": "License Plate Recognition"}, "camera_groups": {"additionalProperties": {"$ref": "#/$defs/CameraGroupConfig"}, "description": "Configuration for named camera groups used to organize cameras in the UI.", "title": "Camera groups", "type": "object"}, "profiles": {"additionalProperties": {"$ref": "#/$defs/ProfileDefinitionConfig"}, "description": "Named profile definitions with friendly names. Camera profiles must reference names defined here.", "title": "Profiles", "type": "object"}, "active_profile": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Currently active profile name. Runtime-only, not persisted in YAML.", "title": "Active profile"}}, "required": ["mqtt", "cameras"], "title": "FrigateConfig", "type": "object"} \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/config-snapshot.json b/web/e2e/fixtures/mock-data/config-snapshot.json index b108416c9a..9095bc350a 100644 --- a/web/e2e/fixtures/mock-data/config-snapshot.json +++ b/web/e2e/fixtures/mock-data/config-snapshot.json @@ -1 +1 @@ -{"version": null, "safe_mode": false, "environment_vars": {}, "logger": {"default": "info", "logs": {}}, "auth": {"enabled": true, "reset_admin_password": false, "cookie_name": "frigate_token", "cookie_secure": false, "session_length": 86400, "refresh_time": 1800, "failed_login_rate_limit": null, "trusted_proxies": [], "hash_iterations": 600000, "roles": {"admin": [], "viewer": []}, "admin_first_time_login": false}, "database": {"path": "/config/frigate.db"}, "go2rtc": {}, "mqtt": {"enabled": true, "host": "mqtt", "port": 1883, "topic_prefix": "frigate", "client_id": "frigate", "stats_interval": 60, "user": null, "password": null, "tls_ca_certs": null, "tls_client_cert": null, "tls_client_key": null, "tls_insecure": null, "qos": 0}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "networking": {"ipv6": {"enabled": false}, "listen": {"internal": 5000, "external": 8971}}, "proxy": {"header_map": {"user": null, "role": null, "role_map": {}}, "logout_url": null, "auth_secret": null, "default_role": "viewer", "separator": ","}, "telemetry": {"network_interfaces": [], "stats": {"amd_gpu_stats": true, "intel_gpu_stats": true, "network_bandwidth": false, "intel_gpu_device": null}, "version_check": true}, "tls": {"enabled": true}, "ui": {"timezone": null, "time_format": "browser", "unit_system": "metric"}, "models": [{"scene": "all", "devices": ["cpu"], "path": "/cpu_model.tflite", "labelmap_path": null, "width": 320, "height": 320, "labelmap": {}, "attributes_map": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "input_tensor": "nhwc", "input_pixel_format": "rgb", "input_dtype": "int", "model_type": "ssd", "all_attributes": ["amazon", "an_post", "canada_post", "dhl", "dpd", "face", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "colormap": {}}], "genai": {}, "cameras": {"front_door": {"name": "front_door", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "modes": ["all_objects"], "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "scene": null, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac", "record_sub": []}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"front_door": "front_door"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "", "max_concurrent": 3, "chapters": "review_items"}, "preview": {"quality": "medium"}, "sub": {"enabled": false, "continuous": {"days": 0}, "motion": {"days": 0}, "alerts": {"days": 10, "mode": "motion"}, "detections": {"days": 10, "mode": "motion"}}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "backyard": {"name": "backyard", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "modes": ["all_objects"], "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "scene": null, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac", "record_sub": []}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"backyard": "backyard"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "", "max_concurrent": 3, "chapters": "review_items"}, "preview": {"quality": "medium"}, "sub": {"enabled": false, "continuous": {"days": 0}, "motion": {"days": 0}, "alerts": {"days": 10, "mode": "motion"}, "detections": {"days": 10, "mode": "motion"}}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "garage": {"name": "garage", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "modes": ["all_objects"], "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "scene": null, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac", "record_sub": []}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"garage": "garage"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "", "max_concurrent": 3, "chapters": "review_items"}, "preview": {"quality": "medium"}, "sub": {"enabled": false, "continuous": {"days": 0}, "motion": {"days": 0}, "alerts": {"days": 10, "mode": "motion"}, "detections": {"days": 10, "mode": "motion"}}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}}, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": null, "num_threads": 2}, "birdseye": {"enabled": true, "modes": ["all_objects"], "restream": false, "width": 1280, "height": 720, "quality": 8, "inactivity_threshold": 30, "layout": {"scaling_factor": 2.0, "max_cameras": null}, "idle_heartbeat_fps": 0.0}, "detect": {"enabled": false, "height": null, "width": null, "scene": null, "fps": 5, "min_initialized": null, "max_disappeared": null, "stationary": {"interval": null, "threshold": null, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac", "record_sub": []}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0}, "live": {"streams": [], "height": 720, "quality": 8}, "motion": null, "objects": {"track": ["person"], "filters": {"amazon": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "an_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "canada_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dhl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dpd": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "face": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "fedex": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "gls": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "license_plate": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "nzpost": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnord": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "purolator": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "royal_mail": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "ups": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "usps": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": null}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "", "max_concurrent": 3, "chapters": "review_items"}, "preview": {"quality": "medium"}, "sub": {"enabled": false, "continuous": {"days": 0}, "motion": {"days": 0}, "alerts": {"days": 10, "mode": "motion"}, "detections": {"days": 10, "mode": "motion"}}, "enabled_in_config": null}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": null, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": null}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": null, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "audio_transcription": {"enabled": false, "language": "en", "device": "CPU", "model_size": "small", "live_enabled": false}, "classification": {"bird": {"enabled": false, "threshold": 0.9}, "custom": {}}, "semantic_search": {"enabled": false, "reindex": false, "model": "jinav1", "model_size": "small", "device": null}, "face_recognition": {"enabled": false, "model_size": "small", "unknown_score": 0.8, "detection_threshold": 0.7, "recognition_threshold": 0.9, "min_area": 750, "min_faces": 1, "save_attempts": 200, "blur_confidence_filter": true, "device": null}, "lpr": {"enabled": false, "model_size": "small", "detection_threshold": 0.7, "min_area": 1000, "recognition_threshold": 0.9, "min_plate_length": 4, "format": null, "match_distance": 1, "known_plates": {}, "enhancement": 0, "debug_save_plates": false, "device": null, "replace_rules": []}, "camera_groups": {"default": {"cameras": ["front_door", "backyard", "garage"], "icon": "generic", "order": 0}, "outdoor": {"cameras": ["front_door", "backyard"], "icon": "generic", "order": 1}}, "profiles": {}} \ No newline at end of file +{"version": null, "safe_mode": false, "environment_vars": {}, "logger": {"default": "info", "logs": {}}, "auth": {"enabled": true, "reset_admin_password": false, "cookie_name": "frigate_token", "cookie_secure": false, "session_length": 86400, "refresh_time": 1800, "failed_login_rate_limit": null, "trusted_proxies": [], "hash_iterations": 600000, "roles": {"admin": [], "viewer": []}, "admin_first_time_login": false}, "database": {"path": "/config/frigate.db"}, "go2rtc": {}, "mqtt": {"enabled": true, "host": "mqtt", "port": 1883, "topic_prefix": "frigate", "client_id": "frigate", "stats_interval": 60, "user": null, "password": null, "tls_ca_certs": null, "tls_client_cert": null, "tls_client_key": null, "tls_insecure": null, "qos": 0}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "networking": {"ipv6": {"enabled": false}, "listen": {"internal": 5000, "external": 8971}}, "proxy": {"header_map": {"user": null, "role": null, "role_map": {}}, "logout_url": null, "auth_secret": null, "default_role": "viewer", "separator": ","}, "telemetry": {"network_interfaces": [], "stats": {"amd_gpu_stats": true, "intel_gpu_stats": true, "network_bandwidth": false, "intel_gpu_device": null}, "version_check": true}, "tls": {"enabled": true}, "ui": {"timezone": null, "time_format": "browser", "unit_system": "metric"}, "models": [{"scene": "all", "devices": ["cpu"], "path": "/cpu_model.tflite", "labelmap_path": null, "width": 320, "height": 320, "labelmap": {}, "attributes_map": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "input_tensor": "nhwc", "input_pixel_format": "rgb", "input_dtype": "int", "model_type": "ssd", "all_attributes": ["amazon", "an_post", "canada_post", "dhl", "dpd", "face", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "colormap": {}}], "genai": {}, "cameras": {"front_door": {"name": "front_door", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "labelmap": {}, "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "modes": ["all_objects"], "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "scene": "all", "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac", "record_sub": []}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"front_door": "front_door"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "", "max_concurrent": 3, "chapters": "review_items"}, "preview": {"quality": "medium"}, "sub": {"enabled": false, "continuous": {"days": 0}, "motion": {"days": 0}, "alerts": {"days": 10, "mode": "motion"}, "detections": {"days": 10, "mode": "motion"}}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "backyard": {"name": "backyard", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "labelmap": {}, "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "modes": ["all_objects"], "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "scene": "all", "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac", "record_sub": []}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"backyard": "backyard"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "", "max_concurrent": 3, "chapters": "review_items"}, "preview": {"quality": "medium"}, "sub": {"enabled": false, "continuous": {"days": 0}, "motion": {"days": 0}, "alerts": {"days": 10, "mode": "motion"}, "detections": {"days": 10, "mode": "motion"}}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "garage": {"name": "garage", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "labelmap": {}, "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "modes": ["all_objects"], "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "scene": "all", "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac", "record_sub": []}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"garage": "garage"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "", "max_concurrent": 3, "chapters": "review_items"}, "preview": {"quality": "medium"}, "sub": {"enabled": false, "continuous": {"days": 0}, "motion": {"days": 0}, "alerts": {"days": 10, "mode": "motion"}, "detections": {"days": 10, "mode": "motion"}}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}}, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "labelmap": {}, "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": null, "num_threads": 2}, "birdseye": {"enabled": true, "modes": ["all_objects"], "restream": false, "width": 1280, "height": 720, "quality": 8, "inactivity_threshold": 30, "layout": {"scaling_factor": 2.0, "max_cameras": null}, "idle_heartbeat_fps": 0.0}, "detect": {"enabled": false, "height": null, "width": null, "scene": "all", "fps": 5, "min_initialized": null, "max_disappeared": null, "stationary": {"interval": null, "threshold": null, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac", "record_sub": []}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0}, "live": {"streams": [], "height": 720, "quality": 8}, "motion": null, "objects": {"track": ["person"], "filters": {"amazon": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "an_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "canada_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dhl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dpd": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "face": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "fedex": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "gls": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "license_plate": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "nzpost": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnord": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "purolator": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "royal_mail": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "ups": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "usps": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": null}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "", "max_concurrent": 3, "chapters": "review_items"}, "preview": {"quality": "medium"}, "sub": {"enabled": false, "continuous": {"days": 0}, "motion": {"days": 0}, "alerts": {"days": 10, "mode": "motion"}, "detections": {"days": 10, "mode": "motion"}}, "enabled_in_config": null}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": null, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": null}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": null, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "audio_transcription": {"enabled": false, "language": "en", "device": "CPU", "model_size": "small", "live_enabled": false}, "classification": {"bird": {"enabled": false, "threshold": 0.9}, "custom": {}}, "semantic_search": {"enabled": false, "reindex": false, "model": "jinav1", "model_size": "small", "device": null}, "face_recognition": {"enabled": false, "model_size": "small", "unknown_score": 0.8, "detection_threshold": 0.7, "recognition_threshold": 0.9, "min_area": 750, "min_faces": 1, "save_attempts": 200, "blur_confidence_filter": true, "device": null}, "lpr": {"enabled": false, "model_size": "small", "detection_threshold": 0.7, "min_area": 1000, "recognition_threshold": 0.9, "min_plate_length": 4, "format": null, "match_distance": 1, "known_plates": {}, "enhancement": 0, "debug_save_plates": false, "device": null, "replace_rules": []}, "camera_groups": {"default": {"cameras": ["front_door", "backyard", "garage"], "icon": "generic", "order": 0}, "outdoor": {"cameras": ["front_door", "backyard"], "icon": "generic", "order": 1}}, "profiles": {}} \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/hardware.ts b/web/e2e/fixtures/mock-data/hardware.ts new file mode 100644 index 0000000000..c75bcf7d27 --- /dev/null +++ b/web/e2e/fixtures/mock-data/hardware.ts @@ -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, + }, +]; diff --git a/web/e2e/helpers/api-mocker.ts b/web/e2e/helpers/api-mocker.ts index e1a191fe0b..cc062abf79 100644 --- a/web/e2e/helpers/api-mocker.ts +++ b/web/e2e/helpers/api-mocker.ts @@ -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; configRaw?: string; configSchema?: Record; + 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: {} }), diff --git a/web/e2e/specs/settings/detection-models.spec.ts b/web/e2e/specs/settings/detection-models.spec.ts new file mode 100644 index 0000000000..188939dd2e --- /dev/null +++ b/web/e2e/specs/settings/detection-models.spec.ts @@ -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; + attributes_map?: Record; + 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; +}) => { + 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"]); + }); +}); diff --git a/web/e2e/specs/settings/detectors-and-model.spec.ts b/web/e2e/specs/settings/detectors-and-model.spec.ts deleted file mode 100644 index 83328f4b5f..0000000000 --- a/web/e2e/specs/settings/detectors-and-model.spec.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 272e0f2795..5879b5c924 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -129,6 +129,8 @@ "saving": "Saving…", "cancel": "Cancel", "close": "Close", + "expand": "Expand", + "collapse": "Collapse", "copy": "Copy", "copiedToClipboard": "Copied to clipboard", "back": "Back", diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json index 7e01f461f8..10930ea00c 100644 --- a/web/public/locales/en/config/cameras.json +++ b/web/public/locales/en/config/cameras.json @@ -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", diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index 582c1a4324..af1ed4fcbe 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -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", diff --git a/web/public/locales/en/config/validation.json b/web/public/locales/en/config/validation.json index f3d98a65e9..f57ee57309 100644 --- a/web/public/locales/en/config/validation.json +++ b/web/public/locales/en/config/validation.json @@ -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." } } diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index bb8927207e..84c4599fbd 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -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" + } } } diff --git a/web/src/components/config-form/section-configs/detect.ts b/web/src/components/config-form/section-configs/detect.ts index 776fbb6567..13d037cbfa 100644 --- a/web/src/components/config-form/section-configs/detect.ts +++ b/web/src/components/config-form/section-configs/detect.ts @@ -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", diff --git a/web/src/components/config-form/section-configs/detectors.ts b/web/src/components/config-form/section-configs/detectors.ts deleted file mode 100644 index 5cc5a929f5..0000000000 --- a/web/src/components/config-form/section-configs/detectors.ts +++ /dev/null @@ -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; diff --git a/web/src/components/config-form/section-configs/model.ts b/web/src/components/config-form/section-configs/models.ts similarity index 50% rename from web/src/components/config-form/section-configs/model.ts rename to web/src/components/config-form/section-configs/models.ts index 21f5b8ce58..3e09664b4c 100644 --- a/web/src/components/config-form/section-configs/model.ts +++ b/web/src/components/config-form/section-configs/models.ts @@ -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; diff --git a/web/src/components/config-form/section-validations/index.ts b/web/src/components/config-form/section-validations/index.ts index 33c02b1c7a..0ae508c7db 100644 --- a/web/src/components/config-form/section-validations/index.ts +++ b/web/src/components/config-form/section-validations/index.ts @@ -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); } diff --git a/web/src/components/config-form/section-validations/models.ts b/web/src/components/config-form/section-validations/models.ts new file mode 100644 index 0000000000..c129cdec61 --- /dev/null +++ b/web/src/components/config-form/section-validations/models.ts @@ -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; +} diff --git a/web/src/components/config-form/sectionConfigs.ts b/web/src/components/config-form/sectionConfigs.ts index c3a9158a9e..023d77e013 100644 --- a/web/src/components/config-form/sectionConfigs.ts +++ b/web/src/components/config-form/sectionConfigs.ts @@ -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 = { logger, environment_vars: environmentVars, telemetry, - detectors, - model, + models, genai, classification, }; diff --git a/web/src/components/config-form/sections/CameraOverridesBadge.tsx b/web/src/components/config-form/sections/CameraOverridesBadge.tsx index 9d3dde29d6..d686d7f9e1 100644 --- a/web/src/components/config-form/sections/CameraOverridesBadge.tsx +++ b/web/src/components/config-form/sections/CameraOverridesBadge.tsx @@ -72,8 +72,7 @@ const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([ "environment_vars", "telemetry", "birdseye", - "detectors", - "model", + "models", ]); type CameraEntryProps = { diff --git a/web/src/components/config-form/sections/section-special-cases.ts b/web/src/components/config-form/sections/section-special-cases.ts index 84bdaae9a9..08f7d8c736 100644 --- a/web/src/components/config-form/sections/section-special-cases.ts +++ b/web/src/components/config-form/sections/section-special-cases.ts @@ -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; diff --git a/web/src/components/config-form/theme/fields/DetectorHardwareField.tsx b/web/src/components/config-form/theme/fields/DetectorHardwareField.tsx deleted file mode 100644 index b9e15ced2f..0000000000 --- a/web/src/components/config-form/theme/fields/DetectorHardwareField.tsx +++ /dev/null @@ -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; - hiddenFields?: string[]; - typeOrder?: string[]; -}; - -type DetectorSchemaEntry = { - type: string; - schema: RJSFSchema; -}; - -const DEFAULT_MULTI_INSTANCE_TYPES = ["cpu", "onnx", "openvino"]; -const EMPTY_HIDDEN_BY_TYPE: Record = {}; -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; - 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; - const properties = schemaObj.properties as - | Record - | undefined; - const typeSchema = properties?.type as Record | 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; - const defs = - (rootSchema?.$defs as Record | undefined) ?? - (rootSchema?.definitions as Record | undefined); - const fallback = defs?.DetectorConfig; - - return isSchemaObject(fallback) ? (fallback as RJSFSchema) : undefined; - }, [schema, registry.rootSchema]); - - const detectorSchemas = useMemo(() => { - 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(); - 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(orderedTypes[0]); - const [addError, setAddError] = useState(); - const [renameDrafts, setRenameDrafts] = useState>({}); - const [renameErrors, setRenameErrors] = useState>({}); - const [typeErrors, setTypeErrors] = useState>({}); - const [openKeys, setOpenKeys] = useState>( - () => 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(); - Object.keys(detectors).forEach((key) => { - if (prev.has(key)) { - next.add(key); - } - }); - return next; - }); - - setRenameDrafts((prev) => { - const next: Record = {}; - Object.keys(detectors).forEach((key) => { - if (prev[key] !== undefined) { - next[key] = prev[key]; - } - }); - return next; - }); - - setRenameErrors((prev) => { - const next: Record = {}; - Object.keys(detectors).forEach((key) => { - if (prev[key] !== undefined) { - next[key] = prev[key]; - } - }); - return next; - }); - - setTypeErrors((prev) => { - const next: Record = {}; - 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) => - requiresRestartForFieldPath( - path, - restartRequired, - defaultRequiresRestart, - ), - [defaultRequiresRestart, restartRequired], - ); - - const renderRestartIcon = (isRequired: boolean) => { - if (!isRequired) { - return null; - } - - return ; - }; - - 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; - const withDefaults = applySchemaDefaults(schema, base); - return { ...withDefaults, type } as Record; - }, - [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 | undefined - )?.[key]; - - const handleInstanceChange = ( - nextValue: unknown, - path: FieldPathList, - errors?: ErrorSchema, - id?: string, - ) => { - onChange(nextValue, path, errors, id); - }; - - return ( - - ); - }, - [ - detectorSchemaByType, - getInstanceUiSchema, - disabled, - errorSchema, - fieldPathId, - hideError, - onChange, - onBlur, - onFocus, - readonly, - registry, - ], - ); - - if (!availableTypes.length) { - return ( -

- {t("configForm.detectors.noSchema", { - ns: "views/settings", - defaultValue: "No detector schemas are available.", - })} -

- ); - } - - const detectorEntries = Object.entries(detectors); - const isDisabled = Boolean(disabled || readonly); - - return ( -
- {detectorEntries.length === 0 ? ( -

- {t("configForm.detectors.none", { - ns: "views/settings", - defaultValue: "No detector instances configured.", - })} -

- ) : ( -
- {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 ( -
- { - setOpenKeys((prev) => { - const next = new Set(prev); - if (open) { - next.add(key); - } else { - next.delete(key); - } - return next; - }); - }} - > -
-
- - - -
-
- {typeLabel} - {renderRestartIcon(detectorTypeRequiresRestart)} - - {key} - -
- {typeDescription && ( -
- {typeDescription} -
- )} -
-
- -
- -
-
-
- - { - 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); - } - }} - /> -

- {t("field.internalID", { - ns: "common", - defaultValue: - "The Internal ID Frigate uses in the configuration and database", - })} -

- {renameErrors[key] && ( -

- {renameErrors[key]} -

- )} -
-
- - - {typeErrors[key] && ( -

- {typeErrors[key]} -

- )} -
-
- -
- {renderInstanceForm(key, value)} -
-
-
-
-
- ); - })} -
- )} - -
-
-
- {t("configForm.detectors.add", { - ns: "views/settings", - defaultValue: "Add detector", - })} -
-
-
- - - {addError &&

{addError}

} -
-
- -
-
-
-
-
- ); -} diff --git a/web/src/components/config-form/theme/fields/HardwarePicker.tsx b/web/src/components/config-form/theme/fields/HardwarePicker.tsx new file mode 100644 index 0000000000..98382a8bf7 --- /dev/null +++ b/web/src/components/config-form/theme/fields/HardwarePicker.tsx @@ -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; + 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("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 ( +

+ {t("detectionModels.hardware.loading")} +

+ ); + } + + // a hand-written config can name hardware this system does not report + const unrecognized = devices.length > 0 && !selected; + + return ( +
+
+ + +

+ {t("detectionModels.hardware.description")} +

+
+ + {unrecognized ? ( +

+ {t("detectionModels.hardware.unrecognized", { + devices: devices.join(", "), + })} +

+ ) : null} + + {selected && (selected.units.length > 1 || !selected.unlimited) ? ( +
+

+ {t("detectionModels.hardware.unitsDescription")} +

+ {selected.units.map((unit) => { + const claimedBy = claimedElsewhere[unit.device]; + + return ( +
+ + handleUnitToggle(unit.device, checked === true) + } + /> + +
+ ); + })} +
+ ) : null} + + {selected?.unlimited ? ( +
+ + +

+ {t("detectionModels.hardware.detectorCountDescription")} +

+
+ ) : null} +
+ ); +} + +export default HardwarePicker; diff --git a/web/src/components/config-form/theme/fields/ModelSourcePicker.tsx b/web/src/components/config-form/theme/fields/ModelSourcePicker.tsx new file mode 100644 index 0000000000..8240a957f3 --- /dev/null +++ b/web/src/components/config-form/theme/fields/ModelSourcePicker.tsx @@ -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://` 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("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 + >(plusEnabled ? "/plus/models" : null, { + fetcher: async (url) => { + const res = await axios.get(url, { withCredentials: true }); + return res.data.reduce( + (obj: Record, 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
{customFields}
; + } + + 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 ( + + + {t("detectionModels.tabs.plus")} + + {t("detectionModels.tabs.custom")} + + + + + + +

+ frigatePlus.modelInfo.modelSelect +

+
+ + + {customFields} + +
+ ); +} + +export default ModelSourcePicker; diff --git a/web/src/components/config-form/theme/fields/ModelsField.tsx b/web/src/components/config-form/theme/fields/ModelsField.tsx new file mode 100644 index 0000000000..c3748c25fb --- /dev/null +++ b/web/src/components/config-form/theme/fields/ModelsField.tsx @@ -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 => { + if (!schema || typeof schema.properties !== "object" || !schema.properties) { + return {}; + } + + return schema.properties as Record; +}; + +const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => { + const scene = getItemProperties(itemSchema).scene as + | Record + | 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>({}); + + // shared with HardwarePicker through the SWR cache, so this is not a second + // request + const { data: hardware } = useSWR("hardware/probe"); + + useEffect(() => { + setOpenByIndex((previous) => { + const next: Record = {}; + 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 => { + const claimed: Record = {}; + + 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) => { + 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 = {}; + 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 | undefined + )?.[index] as Record | undefined; + + return ( + )?.[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 ( +
+ {models.map((model, index) => { + const open = openByIndex[index] ?? true; + const takenScenes = new Set( + models + .filter((_, currentIndex) => currentIndex !== index) + .map((other) => other.scene), + ); + + return ( + + + setOpenByIndex((previous) => ({ + ...previous, + [index]: nextOpen, + })) + } + > + +
+ + + + {t(`detectionModels.scenes.${model.scene ?? "all"}`)} + + + {summarizeDevices( + hardware ?? [], + model.devices ?? [], + ) ?? t("detectionModels.hardware.none")} + {" • "} + {t("detectionModels.cameras", { + count: cameraCountForScene(model.scene), + })} + + + +
+ {models.length > 1 ? ( + + + + + + {t("button.delete", { ns: "common" })} + + + ) : null} + + + +
+
+
+ + + +
+ + +

+ {t("detectionModels.scene.description")} +

+
+ + updateModel(index, { devices })} + /> + + updateModel(index, { path })} + customFields={CUSTOM_MODEL_FIELDS.map((fieldName) => + renderField(index, fieldName), + )} + /> +
+
+
+
+ ); + })} + + {models.length < sceneOptions.length ? ( + + ) : null} +
+ ); +} + +export default ModelsField; diff --git a/web/src/components/config-form/theme/fields/index.ts b/web/src/components/config-form/theme/fields/index.ts index 4bb91ed352..633e51b474 100644 --- a/web/src/components/config-form/theme/fields/index.ts +++ b/web/src/components/config-form/theme/fields/index.ts @@ -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"; diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 6d2bc27451..8008ac272e 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -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, diff --git a/web/src/components/config-form/theme/templates/ErrorListTemplate.tsx b/web/src/components/config-form/theme/templates/ErrorListTemplate.tsx index d30aa9f531..a0edc9e7ab 100644 --- a/web/src/components/config-form/theme/templates/ErrorListTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ErrorListTemplate.tsx @@ -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( diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index 51817c4395..3d2a21d628 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -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; diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index 009a53b588..9eecd30497 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -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]; diff --git a/web/src/components/config-form/theme/utils/i18n.ts b/web/src/components/config-form/theme/utils/i18n.ts index a5f7ea1527..2d18558f18 100644 --- a/web/src/components/config-form/theme/utils/i18n.ts +++ b/web/src/components/config-form/theme/utils/i18n.ts @@ -8,62 +8,20 @@ import type { ConfigFormContext } from "@/types/configForm"; import { getEffectiveAttributeLabels } from "@/utils/configUtil"; -const isRecord = (value: unknown): value is Record => - 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, - 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("."); } diff --git a/web/src/lib/config-schema/transformer.ts b/web/src/lib/config-schema/transformer.ts index 797b07ee58..6b2ae86d13 100644 --- a/web/src/lib/config-schema/transformer.ts +++ b/web/src/lib/config-schema/transformer.ts @@ -736,6 +736,13 @@ export function applySchemaDefaults( schema: RJSFSchema, formData: Record = {}, ): Record { + // 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; + } + const result = { ...formData }; const schemaObj = schema as Record; diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 278a19c776..d33e64087a 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -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 = { environment_vars: "systemEnvironmentVariables", telemetry: "systemTelemetry", birdseye: "systemBirdseye", - detectors: "systemDetectorsAndModel", - model: "systemDetectorsAndModel", + models: "systemDetectorsAndModel", }; const CAMERA_SECTION_KEYS = new Set( @@ -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 = {}; - 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) { diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 76e87caa5c..fcb6a9aace 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -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: { diff --git a/web/src/types/hardware.ts b/web/src/types/hardware.ts new file mode 100644 index 0000000000..e77de8cd93 --- /dev/null +++ b/web/src/types/hardware.ts @@ -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; +}; diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index ad7d659213..ae377db408 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -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; } diff --git a/web/src/utils/detectionHardware.ts b/web/src/utils/detectionHardware.ts new file mode 100644 index 0000000000..622cf4f184 --- /dev/null +++ b/web/src/utils/detectionHardware.ts @@ -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(); + devices.forEach((device) => + counts.set(device, (counts.get(device) ?? 0) + 1), + ); + + return [...counts.entries()] + .map(([device, count]) => (count > 1 ? `${device} ×${count}` : device)) + .join(", "); +} diff --git a/web/src/views/settings/DetectorsAndModelSettingsView.tsx b/web/src/views/settings/DetectorsAndModelSettingsView.tsx deleted file mode 100644 index 46a8a80123..0000000000 --- a/web/src/views/settings/DetectorsAndModelSettingsView.tsx +++ /dev/null @@ -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 = { - 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 = {}; - -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; - // 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("config"); - const { mutate: globalMutate } = useSWRConfig(); - const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; - - // track the saved config - const snapshot = useMemo( - () => (config ? deriveInitialState(config) : null), - [config], - ); - const [state, setState] = useState(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({ - hasChanges: false, - isOverridden: false, - hasValidationErrors: false, - }); - const [modelStatus, setModelStatus] = useState({ - 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 - >(plusEnabled ? "/plus/models" : null, { - fallbackData: {}, - fetcher: async (url) => { - const res = await axios.get(url, { withCredentials: true }); - return res.data.reduce( - (obj: Record, 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(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: ( - - ), - }); - } 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 ; - } - - const saveDisabled = - !isDirty || - isSaving || - isSavingAll || - detectorStatus.hasValidationErrors || - (state.modelTab === "custom" && modelStatus.hasValidationErrors) || - plusMismatch || - plusModelMissing; - - return ( -
-
-
- {t("detectorsAndModel.title")} -
- {t("detectorsAndModel.description")} -
-
- - {t("readTheDocumentation", { ns: "common" })} - - -
-
- {isDirty && ( - - {t("button.modified", { ns: "common", defaultValue: "Modified" })} - - )} -
-
-
- - - - {plusMismatch && selectedPlusModel && ( - true, - values: { - model: selectedPlusModel.name, - required: selectedPlusModel.supportedDetectors.join(", "), - }, - }, - ]} - /> - )} - - {plusEnabled ? ( - - handleModelTabChange(value as ModelTab) - } - > - - - {t("detectorsAndModel.tabs.plus")} - - - {t("detectorsAndModel.tabs.custom")} - - - - - - frigatePlus.modelInfo.modelSelect - - } - content={ -
- - - - - - -
-
- {t("frigatePlus.modelInfo.filter.ariaLabel")} -
-
- - -
-
- - -
-
-
-
-
- } - /> -
- - - - -
- ) : ( - - )} -
-
-
- -
-
- {isDirty && ( - - {t("unsavedChanges", { ns: "views/settings" })} - - )} -
- {isDirty && ( - - )} - -
-
-
- setRestartDialogOpen(false)} - onRestart={() => sendRestart("restart")} - /> -
- ); -}