diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 1f831cb79c..a279686700 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -124,5 +124,7 @@ jobs: run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate" - name: Check API spec is up to date run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check" + - name: Check analytics schema is up to date + run: devcontainer exec --workspace-folder . bash -lc "python3 generate_analytics_schema.py --check" - name: Run unit tests in devcontainer run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m frigate.test" diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 3e9826f37c..8da5c2404a 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -383,6 +383,7 @@ FROM deps AS frigate WORKDIR /opt/frigate/ COPY --from=rootfs / / +ENV FRIGATE_IMAGE_VARIANT=standard # Pre-compile bytecode so a read-only rootfs doesn't force re-parsing the # source tree on every boot (pip-installed packages are already compiled) diff --git a/docker/rockchip/Dockerfile b/docker/rockchip/Dockerfile index 70309f02ed..1fc56a46c9 100644 --- a/docker/rockchip/Dockerfile +++ b/docker/rockchip/Dockerfile @@ -25,6 +25,7 @@ RUN --mount=type=bind,from=rk-wheels,source=/rk-wheels,target=/deps/rk-wheels \ WORKDIR /opt/frigate/ COPY --from=rootfs / / +ENV FRIGATE_IMAGE_VARIANT=rk COPY docker/rockchip/COCO /COCO COPY docker/rockchip/conv2rknn.py /opt/conv2rknn.py diff --git a/docker/rocm/Dockerfile b/docker/rocm/Dockerfile index da44cf25a0..89e108ffe5 100644 --- a/docker/rocm/Dockerfile +++ b/docker/rocm/Dockerfile @@ -37,6 +37,7 @@ RUN --mount=type=bind,source=docker/rocm/prune_rocm.sh,target=/deps/prune_rocm.s WORKDIR /opt/frigate COPY --from=rootfs / / +ENV FRIGATE_IMAGE_VARIANT=rocm ####################################################################### FROM deps-prelim AS rocm-prelim-hsa-override0 diff --git a/docker/rpi/Dockerfile b/docker/rpi/Dockerfile index 35a2252276..41abb24bc8 100644 --- a/docker/rpi/Dockerfile +++ b/docker/rpi/Dockerfile @@ -15,3 +15,4 @@ ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:${INCLUDED_FFMPEG_VERSIO WORKDIR /opt/frigate/ COPY --from=rootfs / / +ENV FRIGATE_IMAGE_VARIANT=rpi diff --git a/docker/synaptics/Dockerfile b/docker/synaptics/Dockerfile index 6a60fe43b1..9625249691 100644 --- a/docker/synaptics/Dockerfile +++ b/docker/synaptics/Dockerfile @@ -22,6 +22,7 @@ pip3 install --no-deps -U /deps/synap-wheels/*.whl WORKDIR /opt/frigate/ COPY --from=rootfs / / +ENV FRIGATE_IMAGE_VARIANT=synaptics COPY --from=synap1680-wheels /rootfs/usr/local/lib/*.so /usr/lib diff --git a/docker/tensorrt/Dockerfile.amd64 b/docker/tensorrt/Dockerfile.amd64 index cdf5df9ff8..e0de41fb60 100644 --- a/docker/tensorrt/Dockerfile.amd64 +++ b/docker/tensorrt/Dockerfile.amd64 @@ -25,6 +25,7 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels && pip3 install -U /deps/trt-wheels/*.whl COPY --from=rootfs / / +ENV FRIGATE_IMAGE_VARIANT=tensorrt COPY docker/tensorrt/detector/rootfs/etc/ld.so.conf.d /etc/ld.so.conf.d RUN ldconfig diff --git a/docker/tensorrt/Dockerfile.arm64 b/docker/tensorrt/Dockerfile.arm64 index dd3c5de5e3..0c8091a0fa 100644 --- a/docker/tensorrt/Dockerfile.arm64 +++ b/docker/tensorrt/Dockerfile.arm64 @@ -151,6 +151,7 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels WORKDIR /opt/frigate/ COPY --from=rootfs / / +ENV FRIGATE_IMAGE_VARIANT=tensorrt-jp6 # Fixes "Error importing detector runtime: /usr/lib/aarch64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block" ENV LD_PRELOAD /usr/lib/aarch64-linux-gnu/libstdc++.so.6 \ No newline at end of file diff --git a/docs/docs/configuration/advanced/analytics.md b/docs/docs/configuration/advanced/analytics.md new file mode 100644 index 0000000000..0171a05810 --- /dev/null +++ b/docs/docs/configuration/advanced/analytics.md @@ -0,0 +1,41 @@ +--- +id: analytics +title: Anonymous Analytics +--- + +import AnalyticsFields from "@site/src/components/AnalyticsFields"; +import NavPath from "@site/src/components/NavPath"; + +Frigate can send one anonymous usage report a day. The reports show the maintainers which hardware to support, which features people use, and how releases perform. Sharing is off until you turn it on. + +## Turning it on + +Enable **Share anonymous analytics** at , or set it in your config: + +```yaml +telemetry: + analytics: true +``` + +The same page has a **Preview the report** button that shows exactly what the next report contains. + +## How it's sent + +- Once a day, as a JSON POST to `https://analytics.frigate.video/report` +- The server looks up your country and region from your IP address and never stores the address +- Raw reports are kept for 60 days; only aggregate totals are published +- A random install ID, stored in `/config/.analytics.json`, keeps your install from being counted twice. Turning sharing off deletes it + +## What's never sent + +- Camera, zone, group, profile, or user names +- Object labels, face names, or license plate text +- IP addresses, hostnames, URLs, or stream paths +- Credentials or API keys +- Events, recordings, or anything from them + +## Every field + +Fields marked public appear in the published totals. The machine-readable schema is [frigate-analytics-schema.json](pathname:///frigate-analytics-schema.json). + + diff --git a/docs/docs/configuration/advanced/reference.md b/docs/docs/configuration/advanced/reference.md index 949847c482..e7b3497933 100644 --- a/docs/docs/configuration/advanced/reference.md +++ b/docs/docs/configuration/advanced/reference.md @@ -1218,6 +1218,9 @@ ui: # Optional: Telemetry configuration telemetry: + # Optional: Share one anonymous usage report a day (default: shown below) + # NOTE: See https://docs.frigate.video/configuration/advanced/analytics for what is sent + analytics: False # Optional: Enabled network interfaces for bandwidth stats monitoring (default: empty list, let nethogs search all) network_interfaces: - eth diff --git a/docs/docs/frigate/network_requirements.md b/docs/docs/frigate/network_requirements.md index f28ecac65e..c100db804b 100644 --- a/docs/docs/frigate/network_requirements.md +++ b/docs/docs/frigate/network_requirements.md @@ -135,6 +135,15 @@ telemetry: version_check: false ``` +### Anonymous Analytics + +If [anonymous analytics](/configuration/advanced/analytics) sharing is turned on, Frigate sends one report a day to `https://analytics.frigate.video`. It's off by default, so no outbound connection happens unless you enable it: + +```yaml +telemetry: + analytics: true +``` + ### Push Notifications When [notifications](/configuration/notifications) are enabled and users have registered for push notifications in the web UI, Frigate sends push messages through the browser vendor's push service (e.g., Google FCM, Mozilla autopush). This requires internet access from the Frigate server to these push endpoints. @@ -171,7 +180,7 @@ To run Frigate in an air-gapped or offline environment: 2. **Pre-download the training base weights**: If you plan to train custom classification models, set `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` before training, then run one training job while online. Without this variable the base weights are cached outside `/config/` and are lost whenever the container is recreated, so a later training run will fail offline. If the machine never has internet access, copy the weights in manually as described below. 3. **Disable version check**: Set `telemetry.version_check: false` in your configuration. 4. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests. -5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers. +5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers, and leave anonymous analytics off (its default). 6. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` environment variables to point to local mirrors. After these steps, Frigate will operate with no outbound internet connections. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 9a1ec1ffe2..41c4d59ae2 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -130,6 +130,7 @@ const sidebars: SidebarsConfig = { label: "Advanced Configuration", items: [ "configuration/advanced/system", + "configuration/advanced/analytics", "configuration/advanced/reference", { type: "link", diff --git a/docs/src/components/AnalyticsFields/index.jsx b/docs/src/components/AnalyticsFields/index.jsx new file mode 100644 index 0000000000..b14b088619 --- /dev/null +++ b/docs/src/components/AnalyticsFields/index.jsx @@ -0,0 +1,60 @@ +import React from "react"; +import schema from "@site/static/frigate-analytics-schema.json"; + +function resolve(node) { + if (!node) return node; + if (node.$ref) return schema.$defs[node.$ref.split("/").pop()]; + if (node.anyOf) { + const inner = node.anyOf.find((option) => option.type !== "null"); + return inner ? resolve(inner) : node; + } + return node; +} + +function rows(properties, prefix = "") { + return Object.entries(properties).flatMap(([name, field]) => { + const path = prefix ? `${prefix}.${name}` : name; + const row = { + path, + description: field.description, + isPublic: field["x-public"], + }; + const target = resolve(field); + + if (target?.properties) return [row, ...rows(target.properties, path)]; + + const values = resolve(target?.additionalProperties); + if (values?.properties) + return [row, ...rows(values.properties, `${path}.`)]; + + const items = resolve(target?.items); + if (items?.properties) return [row, ...rows(items.properties, `${path}[]`)]; + + return [row]; + }); +} + +export default function AnalyticsFields() { + return ( + + + + + + + + + + {rows(schema.properties).map((row) => ( + + + + + + ))} + +
FieldDescriptionPublic
+ {row.path} + {row.description}{row.isPublic ? "Yes" : "No"}
+ ); +} diff --git a/docs/static/frigate-analytics-schema.json b/docs/static/frigate-analytics-schema.json new file mode 100644 index 0000000000..d861a44c08 --- /dev/null +++ b/docs/static/frigate-analytics-schema.json @@ -0,0 +1,1492 @@ +{ + "$defs": { + "Arch": { + "enum": [ + "x86_64", + "aarch64", + "other" + ], + "title": "Arch", + "type": "string" + }, + "BirdseyeModeEnum": { + "enum": [ + "continuous", + "motion", + "all_objects", + "alerts", + "detections" + ], + "title": "BirdseyeModeEnum", + "type": "string" + }, + "BirdseyeUsage": { + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Whether birdseye is on", + "title": "Enabled", + "type": "boolean", + "x-public": true + }, + "modes": { + "description": "Birdseye modes", + "items": { + "$ref": "#/$defs/BirdseyeModeEnum" + }, + "title": "Modes", + "type": "array", + "x-public": true + }, + "restream": { + "description": "Whether birdseye is restreamed", + "title": "Restream", + "type": "boolean", + "x-public": true + } + }, + "required": [ + "enabled", + "modes", + "restream" + ], + "title": "BirdseyeUsage", + "type": "object" + }, + "CameraTypeEnum": { + "enum": [ + "generic", + "lpr" + ], + "title": "CameraTypeEnum", + "type": "string" + }, + "CamerasSection": { + "additionalProperties": false, + "properties": { + "audio": { + "description": "Cameras with audio detection on", + "minimum": 0, + "title": "Audio", + "type": "integer", + "x-public": true + }, + "audio_transcription": { + "description": "Cameras with audio detection and transcription on", + "minimum": 0, + "title": "Audio Transcription", + "type": "integer", + "x-public": true + }, + "autotracking": { + "description": "Cameras with PTZ autotracking on", + "minimum": 0, + "title": "Autotracking", + "type": "integer", + "x-public": true + }, + "birdseye": { + "description": "Cameras in birdseye", + "minimum": 0, + "title": "Birdseye", + "type": "integer", + "x-public": true + }, + "cameras_with_zones": { + "description": "Cameras with at least one zone", + "minimum": 0, + "title": "Cameras With Zones", + "type": "integer", + "x-public": true + }, + "connection_quality": { + "additionalProperties": { + "type": "integer" + }, + "description": "Cameras by connection quality at send time", + "propertyNames": { + "$ref": "#/$defs/ConnectionQuality" + }, + "title": "Connection Quality", + "type": "object", + "x-public": true + }, + "detect": { + "description": "Cameras with detection on", + "minimum": 0, + "title": "Detect", + "type": "integer", + "x-public": true + }, + "detect_fps": { + "additionalProperties": { + "type": "integer" + }, + "description": "Cameras by detect fps", + "propertyNames": { + "$ref": "#/$defs/FpsBucket" + }, + "title": "Detect Fps", + "type": "object", + "x-public": true + }, + "detect_height": { + "additionalProperties": { + "type": "integer" + }, + "description": "Cameras by detect resolution height", + "propertyNames": { + "$ref": "#/$defs/HeightBucket" + }, + "title": "Detect Height", + "type": "object", + "x-public": true + }, + "enabled": { + "description": "Cameras enabled in config; every count below covers only these", + "minimum": 0, + "title": "Enabled", + "type": "integer", + "x-public": true + }, + "face_recognition": { + "description": "Cameras with face recognition on", + "minimum": 0, + "title": "Face Recognition", + "type": "integer", + "x-public": true + }, + "go2rtc_restream": { + "description": "Cameras with an input from the go2rtc restream", + "minimum": 0, + "title": "Go2Rtc Restream", + "type": "integer", + "x-public": true + }, + "hwaccel": { + "additionalProperties": { + "type": "integer" + }, + "description": "Cameras by the resolved hwaccel preset of the detect input", + "propertyNames": { + "$ref": "#/$defs/HwaccelKey" + }, + "title": "Hwaccel", + "type": "object", + "x-public": true + }, + "input_preset": { + "additionalProperties": { + "type": "integer" + }, + "description": "Cameras by the input preset of the detect input", + "propertyNames": { + "$ref": "#/$defs/InputPresetKey" + }, + "title": "Input Preset", + "type": "object", + "x-public": true + }, + "lpr": { + "description": "Cameras with license plate recognition on", + "minimum": 0, + "title": "Lpr", + "type": "integer", + "x-public": true + }, + "motion_masks": { + "description": "Motion masks across cameras", + "minimum": 0, + "title": "Motion Masks", + "type": "integer", + "x-public": true + }, + "notifications": { + "description": "Cameras with notifications on", + "minimum": 0, + "title": "Notifications", + "type": "integer", + "x-public": true + }, + "object_genai": { + "description": "Cameras with GenAI object descriptions on", + "minimum": 0, + "title": "Object Genai", + "type": "integer", + "x-public": true + }, + "object_masks": { + "description": "Object masks across cameras", + "minimum": 0, + "title": "Object Masks", + "type": "integer", + "x-public": true + }, + "onvif": { + "description": "Cameras with an ONVIF host", + "minimum": 0, + "title": "Onvif", + "type": "integer", + "x-public": true + }, + "record": { + "description": "Cameras with recording on", + "minimum": 0, + "title": "Record", + "type": "integer", + "x-public": true + }, + "retain_days": { + "$ref": "#/$defs/RetainDays", + "description": "Recording retention", + "x-public": true + }, + "review_genai": { + "description": "Cameras with GenAI review summaries on", + "minimum": 0, + "title": "Review Genai", + "type": "integer", + "x-public": true + }, + "separate_detect_stream": { + "description": "Cameras whose detect input differs from their record input", + "minimum": 0, + "title": "Separate Detect Stream", + "type": "integer", + "x-public": true + }, + "snapshots": { + "description": "Cameras with snapshots on", + "minimum": 0, + "title": "Snapshots", + "type": "integer", + "x-public": true + }, + "sub_stream_record": { + "description": "Cameras with sub stream recording on", + "minimum": 0, + "title": "Sub Stream Record", + "type": "integer", + "x-public": true + }, + "total": { + "description": "Configured cameras", + "minimum": 0, + "title": "Total", + "type": "integer", + "x-public": true + }, + "types": { + "additionalProperties": { + "type": "integer" + }, + "description": "Cameras by type", + "propertyNames": { + "$ref": "#/$defs/CameraTypeEnum" + }, + "title": "Types", + "type": "object", + "x-public": true + }, + "zones": { + "description": "Zones across cameras", + "minimum": 0, + "title": "Zones", + "type": "integer", + "x-public": true + } + }, + "required": [ + "total", + "enabled", + "types", + "detect_height", + "detect_fps", + "hwaccel", + "input_preset", + "go2rtc_restream", + "separate_detect_stream", + "detect", + "record", + "sub_stream_record", + "snapshots", + "audio", + "audio_transcription", + "birdseye", + "onvif", + "autotracking", + "face_recognition", + "lpr", + "review_genai", + "object_genai", + "notifications", + "zones", + "cameras_with_zones", + "motion_masks", + "object_masks", + "connection_quality", + "retain_days" + ], + "title": "CamerasSection", + "type": "object" + }, + "ClassificationUsage": { + "additionalProperties": false, + "properties": { + "object": { + "description": "Custom object classification models", + "minimum": 0, + "title": "Object", + "type": "integer", + "x-public": true + }, + "state": { + "description": "Custom state classification models", + "minimum": 0, + "title": "State", + "type": "integer", + "x-public": true + } + }, + "required": [ + "state", + "object" + ], + "title": "ClassificationUsage", + "type": "object" + }, + "ConnectionQuality": { + "enum": [ + "excellent", + "fair", + "poor", + "unusable" + ], + "title": "ConnectionQuality", + "type": "string" + }, + "DecodeFamily": { + "enum": [ + "nvidia", + "vaapi", + "rkmpp", + "intel-qsv", + "jetson", + "rpi", + "apple-silicon", + "other" + ], + "title": "DecodeFamily", + "type": "string" + }, + "DetectionModel": { + "additionalProperties": false, + "properties": { + "default": { + "description": "Whether the model serves cameras that set no scene", + "title": "Default", + "type": "boolean", + "x-public": true + }, + "detector": { + "$ref": "#/$defs/DetectorTypeEnum", + "description": "Detector type the model runs on", + "x-public": true + }, + "devices": { + "description": "Devices the model runs on", + "minimum": 0, + "title": "Devices", + "type": "integer", + "x-public": true + }, + "inference_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Mean inference time across the model's detector processes, null before the first stats", + "title": "Inference Ms", + "x-public": true + }, + "input": { + "description": "Model input size as WxH", + "pattern": "^\\d{1,5}x\\d{1,5}$", + "title": "Input", + "type": "string", + "x-public": true + }, + "model_type": { + "$ref": "#/$defs/ModelTypeEnum", + "description": "Model architecture", + "x-public": true + }, + "source": { + "$ref": "#/$defs/ModelSource", + "description": "Where the model came from", + "x-public": false + } + }, + "required": [ + "default", + "detector", + "devices", + "model_type", + "input", + "source", + "inference_ms" + ], + "title": "DetectionModel", + "type": "object" + }, + "DetectionSection": { + "additionalProperties": false, + "properties": { + "detection_fps": { + "description": "Detections per second across cameras", + "minimum": 0, + "title": "Detection Fps", + "type": "number", + "x-public": true + }, + "models": { + "description": "Detection models", + "items": { + "$ref": "#/$defs/DetectionModel" + }, + "title": "Models", + "type": "array", + "x-public": true + }, + "skipped_fps": { + "description": "Frames per second skipped across cameras", + "minimum": 0, + "title": "Skipped Fps", + "type": "number", + "x-public": true + } + }, + "required": [ + "models", + "detection_fps", + "skipped_fps" + ], + "title": "DetectionSection", + "type": "object" + }, + "DetectorTypeEnum": { + "enum": [ + "axelera", + "axengine", + "cpu", + "deepx", + "edgetpu", + "hailo", + "memryx", + "onnx", + "openvino", + "rknn", + "synaptics", + "teflon_tfl", + "tensorrt", + "zmq" + ], + "title": "DetectorTypeEnum", + "type": "string" + }, + "EnrichmentDevice": { + "enum": [ + "cpu", + "cuda", + "tensorrt", + "migraphx", + "openvino_cpu", + "openvino_gpu", + "openvino_npu", + "other" + ], + "title": "EnrichmentDevice", + "type": "string" + }, + "EnrichmentTiming": { + "enum": [ + "face", + "lpr", + "plate_detection", + "image_embedding", + "text_embedding", + "review_description", + "object_description" + ], + "title": "EnrichmentTiming", + "type": "string" + }, + "EnrichmentUsage": { + "additionalProperties": false, + "properties": { + "device": { + "anyOf": [ + { + "$ref": "#/$defs/EnrichmentDevice" + }, + { + "type": "null" + } + ], + "description": "Device the model loaded on, null when it isn't loaded", + "x-public": true + }, + "enabled": { + "description": "Whether the enrichment is on", + "title": "Enabled", + "type": "boolean", + "x-public": true + }, + "model_size": { + "$ref": "#/$defs/ModelSizeEnum", + "description": "Configured model size", + "x-public": true + } + }, + "required": [ + "enabled", + "model_size", + "device" + ], + "title": "EnrichmentUsage", + "type": "object" + }, + "FeaturesSection": { + "additionalProperties": false, + "properties": { + "audio_transcription": { + "$ref": "#/$defs/TranscriptionUsage", + "description": "Audio transcription", + "x-public": true + }, + "auth": { + "description": "Whether authentication is on", + "title": "Auth", + "type": "boolean", + "x-public": true + }, + "birdseye": { + "$ref": "#/$defs/BirdseyeUsage", + "description": "Birdseye", + "x-public": true + }, + "camera_groups": { + "description": "Camera groups", + "minimum": 0, + "title": "Camera Groups", + "type": "integer", + "x-public": true + }, + "classification_models": { + "$ref": "#/$defs/ClassificationUsage", + "description": "Custom classification models", + "x-public": true + }, + "face_recognition": { + "$ref": "#/$defs/EnrichmentUsage", + "description": "Face recognition", + "x-public": true + }, + "genai": { + "$ref": "#/$defs/GenAIUsage", + "description": "Generative AI providers", + "x-public": true + }, + "lpr": { + "$ref": "#/$defs/EnrichmentUsage", + "description": "License plate recognition", + "x-public": true + }, + "mqtt": { + "description": "Whether MQTT is on", + "title": "Mqtt", + "type": "boolean", + "x-public": true + }, + "notifications": { + "description": "Whether web push notifications are on", + "title": "Notifications", + "type": "boolean", + "x-public": true + }, + "plus_api_key": { + "description": "Whether a Frigate+ API key is set", + "title": "Plus Api Key", + "type": "boolean", + "x-public": false + }, + "profiles": { + "description": "Profiles", + "minimum": 0, + "title": "Profiles", + "type": "integer", + "x-public": true + }, + "proxy_auth": { + "description": "Whether a proxy supplies the user header", + "title": "Proxy Auth", + "type": "boolean", + "x-public": true + }, + "semantic_search": { + "$ref": "#/$defs/SemanticSearchUsage", + "description": "Semantic search", + "x-public": true + }, + "tls": { + "description": "Whether TLS is on", + "title": "Tls", + "type": "boolean", + "x-public": true + }, + "users": { + "additionalProperties": { + "type": "integer" + }, + "description": "Users by role", + "propertyNames": { + "$ref": "#/$defs/UserRole" + }, + "title": "Users", + "type": "object", + "x-public": true + } + }, + "required": [ + "face_recognition", + "lpr", + "semantic_search", + "audio_transcription", + "genai", + "classification_models", + "birdseye", + "mqtt", + "notifications", + "auth", + "proxy_auth", + "tls", + "users", + "camera_groups", + "profiles", + "plus_api_key" + ], + "title": "FeaturesSection", + "type": "object" + }, + "FpsBucket": { + "enum": [ + "le_5", + "6_10", + "gt_10" + ], + "title": "FpsBucket", + "type": "string" + }, + "GenAIProviderEnum": { + "enum": [ + "openai", + "azure_openai", + "gemini", + "ollama", + "llamacpp" + ], + "title": "GenAIProviderEnum", + "type": "string" + }, + "GenAIRoleEnum": { + "enum": [ + "chat", + "descriptions", + "embeddings", + "transcribe" + ], + "title": "GenAIRoleEnum", + "type": "string" + }, + "GenAIUsage": { + "additionalProperties": false, + "properties": { + "providers": { + "additionalProperties": { + "type": "integer" + }, + "description": "Configured GenAI providers by type", + "propertyNames": { + "$ref": "#/$defs/GenAIProviderEnum" + }, + "title": "Providers", + "type": "object", + "x-public": true + }, + "roles": { + "additionalProperties": { + "type": "integer" + }, + "description": "Configured GenAI providers by role", + "propertyNames": { + "$ref": "#/$defs/GenAIRoleEnum" + }, + "title": "Roles", + "type": "object", + "x-public": true + } + }, + "required": [ + "providers", + "roles" + ], + "title": "GenAIUsage", + "type": "object" + }, + "GpuInfo": { + "additionalProperties": false, + "properties": { + "name": { + "description": "GPU name as the hardware reports it", + "maxLength": 64, + "title": "Name", + "type": "string", + "x-public": true + }, + "vendor": { + "$ref": "#/$defs/GpuVendor", + "description": "GPU vendor", + "x-public": true + } + }, + "required": [ + "vendor", + "name" + ], + "title": "GpuInfo", + "type": "object" + }, + "GpuVendor": { + "enum": [ + "intel", + "amd", + "nvidia", + "rockchip", + "rpi", + "apple-silicon", + "other" + ], + "title": "GpuVendor", + "type": "string" + }, + "HardwareKey": { + "enum": [ + "edgetpu:pci", + "edgetpu:usb", + "openvino:GPU", + "openvino:NPU", + "onnx:amd", + "onnx:nvidia", + "onnx:lighter", + "tensorrt", + "hailo", + "memryx", + "deepx", + "rknn", + "axengine", + "axelera", + "synaptics", + "cpu", + "other" + ], + "title": "HardwareKey", + "type": "string" + }, + "HardwareSection": { + "additionalProperties": false, + "properties": { + "cpu_cores": { + "description": "Logical CPU count", + "minimum": 0, + "title": "Cpu Cores", + "type": "integer", + "x-public": true + }, + "cpu_model": { + "description": "CPU or board model as the hardware reports it", + "maxLength": 64, + "title": "Cpu Model", + "type": "string", + "x-public": true + }, + "decode_families": { + "description": "Hardware decode families this system can use", + "items": { + "$ref": "#/$defs/DecodeFamily" + }, + "title": "Decode Families", + "type": "array", + "x-public": true + }, + "detection_hardware": { + "additionalProperties": { + "type": "integer" + }, + "description": "Detection hardware found, as unit counts by kind", + "propertyNames": { + "$ref": "#/$defs/HardwareKey" + }, + "title": "Detection Hardware", + "type": "object", + "x-public": true + }, + "gpus": { + "description": "GPUs the stats collector found", + "items": { + "$ref": "#/$defs/GpuInfo" + }, + "title": "Gpus", + "type": "array", + "x-public": true + }, + "memory_gb": { + "description": "Total memory in GB", + "minimum": 0, + "title": "Memory Gb", + "type": "integer", + "x-public": true + }, + "storage": { + "$ref": "#/$defs/StorageInfo", + "description": "Recordings storage", + "x-public": true + } + }, + "required": [ + "cpu_model", + "cpu_cores", + "memory_gb", + "gpus", + "decode_families", + "detection_hardware", + "storage" + ], + "title": "HardwareSection", + "type": "object" + }, + "HealthSection": { + "additionalProperties": false, + "properties": { + "cpu_percent": { + "description": "System CPU use at send time", + "maximum": 100, + "minimum": 0, + "title": "Cpu Percent", + "type": "integer", + "x-public": true + }, + "enrichment_ms": { + "additionalProperties": { + "type": "number" + }, + "description": "Mean enrichment inference times in milliseconds", + "propertyNames": { + "$ref": "#/$defs/EnrichmentTiming" + }, + "title": "Enrichment Ms", + "type": "object", + "x-public": true + }, + "notices": { + "additionalProperties": { + "$ref": "#/$defs/NoticeCounts" + }, + "description": "Cumulative notice counts by kind since the install ID was created", + "propertyNames": { + "$ref": "#/$defs/NoticeKindKey" + }, + "title": "Notices", + "type": "object", + "x-public": false + }, + "retention_unmet": { + "description": "Whether storage can't keep the configured retention", + "title": "Retention Unmet", + "type": "boolean", + "x-public": true + }, + "uptime_hours": { + "description": "Hours since Frigate started", + "minimum": 0, + "title": "Uptime Hours", + "type": "integer", + "x-public": true + } + }, + "required": [ + "uptime_hours", + "cpu_percent", + "enrichment_ms", + "retention_unmet", + "notices" + ], + "title": "HealthSection", + "type": "object" + }, + "HeightBucket": { + "enum": [ + "le_360", + "480", + "720", + "1080", + "1440", + "ge_2160" + ], + "title": "HeightBucket", + "type": "string" + }, + "HwaccelKey": { + "enum": [ + "rpi-64-h264", + "rpi-64-h265", + "apple-silicon-h264", + "apple-silicon-h265", + "vaapi", + "intel-qsv-h264", + "intel-qsv-h265", + "nvidia", + "jetson-h264", + "jetson-h265", + "rkmpp-no-dump_extra", + "vulkan", + "amd-amf", + "nvidia-h264", + "nvidia-h265", + "nvidia-mjpeg", + "rkmpp", + "rk-h264", + "rk-h265", + "custom", + "none" + ], + "title": "HwaccelKey", + "type": "string" + }, + "ImageVariant": { + "enum": [ + "standard", + "rpi", + "tensorrt", + "tensorrt-jp6", + "rocm", + "rk", + "synaptics", + "dev", + "other" + ], + "title": "ImageVariant", + "type": "string" + }, + "InputPresetKey": { + "enum": [ + "http-jpeg-generic", + "http-mjpeg-generic", + "http-reolink", + "rtmp-generic", + "rtsp-generic", + "rtsp-restream", + "rtsp-restream-low-latency", + "rtsp-udp", + "rtsp-blue-iris", + "custom", + "none" + ], + "title": "InputPresetKey", + "type": "string" + }, + "InstallSection": { + "additionalProperties": false, + "properties": { + "arch": { + "$ref": "#/$defs/Arch", + "description": "CPU architecture", + "x-public": true + }, + "image_variant": { + "$ref": "#/$defs/ImageVariant", + "description": "Published image the install runs", + "x-public": true + }, + "install_type": { + "$ref": "#/$defs/InstallType", + "description": "How Frigate is installed", + "x-public": true + }, + "kernel": { + "description": "Host kernel as major.minor", + "pattern": "^(\\d{1,3}\\.\\d{1,3}|unknown)$", + "title": "Kernel", + "type": "string", + "x-public": true + }, + "run_as_root": { + "description": "Whether the main process runs as root", + "title": "Run As Root", + "type": "boolean", + "x-public": true + }, + "version": { + "description": "Frigate version string", + "maxLength": 32, + "title": "Version", + "type": "string", + "x-public": true + } + }, + "required": [ + "version", + "image_variant", + "install_type", + "arch", + "kernel", + "run_as_root" + ], + "title": "InstallSection", + "type": "object" + }, + "InstallType": { + "enum": [ + "ha_addon", + "docker", + "podman", + "kubernetes", + "unknown" + ], + "title": "InstallType", + "type": "string" + }, + "ModelSizeEnum": { + "enum": [ + "small", + "large" + ], + "title": "ModelSizeEnum", + "type": "string" + }, + "ModelSource": { + "enum": [ + "default", + "plus", + "custom" + ], + "title": "ModelSource", + "type": "string" + }, + "ModelTypeEnum": { + "enum": [ + "dfine", + "rfdetr", + "ssd", + "yolox", + "yolonas", + "yolo-generic" + ], + "title": "ModelTypeEnum", + "type": "string" + }, + "NoticeCounts": { + "additionalProperties": false, + "properties": { + "acknowledgements": { + "description": "Acknowledgements since the install ID was created", + "minimum": 0, + "title": "Acknowledgements", + "type": "integer", + "x-public": true + }, + "mutes": { + "description": "Mutes since the install ID was created", + "minimum": 0, + "title": "Mutes", + "type": "integer", + "x-public": true + }, + "occurrences": { + "description": "Occurrences since the install ID was created", + "minimum": 0, + "title": "Occurrences", + "type": "integer", + "x-public": true + } + }, + "required": [ + "occurrences", + "acknowledgements", + "mutes" + ], + "title": "NoticeCounts", + "type": "object" + }, + "NoticeKindKey": { + "enum": [ + "detector_stuck", + "model_download_failed", + "skipped_detections", + "ffmpeg_high_cpu", + "detect_high_cpu", + "shm_too_low", + "object_processing_behind", + "failed_login" + ], + "title": "NoticeKindKey", + "type": "string" + }, + "RetainBucket": { + "enum": [ + "0", + "1_7", + "8_30", + "gt_30" + ], + "title": "RetainBucket", + "type": "string" + }, + "RetainDays": { + "additionalProperties": false, + "properties": { + "alerts": { + "additionalProperties": { + "type": "integer" + }, + "description": "Recording cameras by alert retention in days", + "propertyNames": { + "$ref": "#/$defs/RetainBucket" + }, + "title": "Alerts", + "type": "object", + "x-public": true + }, + "continuous": { + "additionalProperties": { + "type": "integer" + }, + "description": "Recording cameras by continuous retention in days", + "propertyNames": { + "$ref": "#/$defs/RetainBucket" + }, + "title": "Continuous", + "type": "object", + "x-public": true + }, + "detections": { + "additionalProperties": { + "type": "integer" + }, + "description": "Recording cameras by detection retention in days", + "propertyNames": { + "$ref": "#/$defs/RetainBucket" + }, + "title": "Detections", + "type": "object", + "x-public": true + }, + "motion": { + "additionalProperties": { + "type": "integer" + }, + "description": "Recording cameras by motion retention in days", + "propertyNames": { + "$ref": "#/$defs/RetainBucket" + }, + "title": "Motion", + "type": "object", + "x-public": true + } + }, + "required": [ + "continuous", + "motion", + "alerts", + "detections" + ], + "title": "RetainDays", + "type": "object" + }, + "SemanticSearchModel": { + "enum": [ + "jinav1", + "jinav2", + "genai" + ], + "title": "SemanticSearchModel", + "type": "string" + }, + "SemanticSearchUsage": { + "additionalProperties": false, + "properties": { + "device": { + "anyOf": [ + { + "$ref": "#/$defs/EnrichmentDevice" + }, + { + "type": "null" + } + ], + "description": "Device the model loaded on, null when it isn't loaded", + "x-public": true + }, + "enabled": { + "description": "Whether semantic search is on", + "title": "Enabled", + "type": "boolean", + "x-public": true + }, + "model": { + "anyOf": [ + { + "$ref": "#/$defs/SemanticSearchModel" + }, + { + "type": "null" + } + ], + "description": "Embedding model, genai for a GenAI provider", + "x-public": true + }, + "model_size": { + "$ref": "#/$defs/ModelSizeEnum", + "description": "Configured model size", + "x-public": true + }, + "triggers": { + "description": "Semantic search triggers across cameras", + "minimum": 0, + "title": "Triggers", + "type": "integer", + "x-public": true + } + }, + "required": [ + "enabled", + "model", + "model_size", + "device", + "triggers" + ], + "title": "SemanticSearchUsage", + "type": "object" + }, + "StorageInfo": { + "additionalProperties": false, + "properties": { + "record_fs": { + "description": "Filesystem of the recordings volume", + "maxLength": 16, + "title": "Record Fs", + "type": "string", + "x-public": true + }, + "record_total_gb": { + "description": "Size of the recordings volume in GB", + "minimum": 0, + "title": "Record Total Gb", + "type": "integer", + "x-public": true + }, + "record_used_pct": { + "description": "Percent of the recordings volume in use", + "maximum": 100, + "minimum": 0, + "title": "Record Used Pct", + "type": "integer", + "x-public": true + } + }, + "required": [ + "record_fs", + "record_total_gb", + "record_used_pct" + ], + "title": "StorageInfo", + "type": "object" + }, + "TranscriptionModel": { + "enum": [ + "whisper", + "genai" + ], + "title": "TranscriptionModel", + "type": "string" + }, + "TranscriptionUsage": { + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Whether audio transcription is on", + "title": "Enabled", + "type": "boolean", + "x-public": true + }, + "model": { + "anyOf": [ + { + "$ref": "#/$defs/TranscriptionModel" + }, + { + "type": "null" + } + ], + "description": "Transcription model, genai for a GenAI provider", + "x-public": true + }, + "model_size": { + "$ref": "#/$defs/ModelSizeEnum", + "description": "Configured model size", + "x-public": true + } + }, + "required": [ + "enabled", + "model", + "model_size" + ], + "title": "TranscriptionUsage", + "type": "object" + }, + "UserRole": { + "enum": [ + "admin", + "viewer", + "custom" + ], + "title": "UserRole", + "type": "string" + } + }, + "$id": "https://docs.frigate.video/frigate-analytics-schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "cameras": { + "anyOf": [ + { + "$ref": "#/$defs/CamerasSection" + }, + { + "type": "null" + } + ], + "description": "Cameras, null if its collector failed", + "x-public": true + }, + "detection": { + "anyOf": [ + { + "$ref": "#/$defs/DetectionSection" + }, + { + "type": "null" + } + ], + "description": "Object detection, null if its collector failed", + "x-public": true + }, + "features": { + "anyOf": [ + { + "$ref": "#/$defs/FeaturesSection" + }, + { + "type": "null" + } + ], + "description": "Features, null if its collector failed", + "x-public": true + }, + "hardware": { + "anyOf": [ + { + "$ref": "#/$defs/HardwareSection" + }, + { + "type": "null" + } + ], + "description": "Hardware, null if its collector failed", + "x-public": true + }, + "health": { + "anyOf": [ + { + "$ref": "#/$defs/HealthSection" + }, + { + "type": "null" + } + ], + "description": "Health, null if its collector failed", + "x-public": true + }, + "install": { + "anyOf": [ + { + "$ref": "#/$defs/InstallSection" + }, + { + "type": "null" + } + ], + "description": "Install, null if its collector failed", + "x-public": true + }, + "install_id": { + "description": "Random install identifier", + "pattern": "^[0-9a-f]{32}$", + "title": "Install Id", + "type": "string", + "x-public": false + }, + "report_id": { + "description": "Random identifier of this report", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "title": "Report Id", + "type": "string", + "x-public": false + }, + "schema_version": { + "description": "Report format version", + "minimum": 1, + "title": "Schema Version", + "type": "integer", + "x-public": true + }, + "sent_at": { + "description": "Unix time the report was built", + "minimum": 0, + "title": "Sent At", + "type": "integer", + "x-public": true + } + }, + "required": [ + "schema_version", + "install_id", + "report_id", + "sent_at", + "install", + "hardware", + "detection", + "cameras", + "features", + "health" + ], + "title": "AnalyticsReport", + "type": "object", + "x-schema-version": 1 +} diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index ca73bf24f3..85300dd272 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -10,6 +10,25 @@ servers: - url: https://demo.frigate.video/api - url: http://localhost:5001/api paths: + /analytics/preview: + get: + tags: + - Analytics + summary: Get Analytics Preview + description: |- + **Access:** Admin role required. + + Get the analytics report Frigate would send next, without sending it. + operationId: get_analytics_preview_analytics_preview_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin /auth/first_time_login: get: tags: diff --git a/frigate/analytics/__init__.py b/frigate/analytics/__init__.py new file mode 100644 index 0000000000..78f91961d3 --- /dev/null +++ b/frigate/analytics/__init__.py @@ -0,0 +1 @@ +"""Opt-in anonymous analytics reports.""" diff --git a/frigate/analytics/collectors/__init__.py b/frigate/analytics/collectors/__init__.py new file mode 100644 index 0000000000..fd4d810734 --- /dev/null +++ b/frigate/analytics/collectors/__init__.py @@ -0,0 +1 @@ +"""One collector per report section.""" diff --git a/frigate/analytics/collectors/cameras.py b/frigate/analytics/collectors/cameras.py new file mode 100644 index 0000000000..fe603c8c73 --- /dev/null +++ b/frigate/analytics/collectors/cameras.py @@ -0,0 +1,238 @@ +"""Cameras section: counts and histograms across cameras, never per camera.""" + +from collections import Counter +from typing import Any +from urllib.parse import urlsplit + +from frigate.analytics.collectors.common import closed, histogram +from frigate.analytics.context import ReportContext +from frigate.analytics.schema import ( + CamerasSection, + ConnectionQuality, + FpsBucket, + HeightBucket, + HwaccelKey, + InputPresetKey, + RetainBucket, + RetainDays, +) +from frigate.config import CameraConfig +from frigate.config.camera.camera import CameraTypeEnum +from frigate.config.camera.ffmpeg import CameraInput, CameraRoleEnum +from frigate.const import REPLAY_CAMERA_PREFIX + +# the upper edge of every height bucket but the last +HEIGHT_BUCKETS = ( + (360, HeightBucket.le_360), + (540, HeightBucket.h480), + (900, HeightBucket.h720), + (1260, HeightBucket.h1080), + (1800, HeightBucket.h1440), +) +RESTREAM_HOSTS = frozenset({"127.0.0.1", "localhost"}) +RESTREAM_PORT = 8554 +QUALITIES = frozenset(ConnectionQuality) + + +def height_bucket(height: int) -> HeightBucket: + """The nearest standard resolution bucket for a detect height.""" + for limit, bucket in HEIGHT_BUCKETS: + if height <= limit: + return bucket + + return HeightBucket.ge_2160 + + +def fps_bucket(fps: int) -> FpsBucket: + """The bucket for a detect fps.""" + if fps <= 5: + return FpsBucket.le_5 + + if fps <= 10: + return FpsBucket.f6_10 + + return FpsBucket.gt_10 + + +def retain_bucket(days: float) -> RetainBucket: + """The bucket for a retention period in days.""" + if days <= 0: + return RetainBucket.zero + + if days <= 7: + return RetainBucket.d1_7 + + if days <= 30: + return RetainBucket.d8_30 + + return RetainBucket.gt_30 + + +def preset_key(args: str | list[str], enum: Any) -> Any: + """The preset's name, custom for hand written args, or none.""" + if not args: + return enum("none") + + if isinstance(args, str) and args.startswith("preset-"): + return closed(enum, args.removeprefix("preset-"), enum("custom")) + + return enum("custom") + + +def is_restream(path: str) -> bool: + """Whether an input reads from the local go2rtc restream.""" + try: + url = urlsplit(path) + return url.hostname in RESTREAM_HOSTS and url.port == RESTREAM_PORT + except ValueError: + return False + + +def role_input(camera: CameraConfig, role: CameraRoleEnum) -> CameraInput | None: + """The camera's first input with the given role.""" + return next((i for i in camera.ffmpeg.inputs if role in i.roles), None) + + +def object_masks(camera: CameraConfig) -> int: + """Camera and per-label object masks, minus the global_ filter copies.""" + own = sum(1 for mask in camera.objects.mask.values() if mask is not None) + per_label = sum( + 1 + for label_filter in camera.objects.filters.values() + for mask_id, mask in label_filter.mask.items() + if mask is not None and not mask_id.startswith("global_") + ) + return own + per_label + + +def collect(ctx: ReportContext) -> CamerasSection: + """Count camera settings across the cameras enabled in config.""" + cameras = { + name: camera + for name, camera in ctx.config.cameras.items() + if not name.startswith(REPLAY_CAMERA_PREFIX) + } + camera_stats = ctx.stats.get("cameras", {}) + + # cameras disabled in config are parked, so they'd skew feature adoption + active = [ + (name, camera) for name, camera in cameras.items() if camera.enabled_in_config + ] + + flags: Counter[str] = Counter() + types: Counter[CameraTypeEnum] = Counter() + heights: Counter[HeightBucket] = Counter() + fps: Counter[FpsBucket] = Counter() + hwaccel: Counter[Any] = Counter() + input_presets: Counter[Any] = Counter() + quality: Counter[ConnectionQuality] = Counter() + retain: dict[str, Counter[RetainBucket]] = { + period: Counter() for period in ("continuous", "motion", "alerts", "detections") + } + + for name, camera in active: + detect_input = role_input(camera, CameraRoleEnum.detect) + record_input = role_input(camera, CameraRoleEnum.record) + + # config validation requires a detect input, so this never skips + if detect_input is None: + continue + + types[camera.type] += 1 + fps[fps_bucket(camera.detect.fps)] += 1 + hwaccel[ + preset_key( + detect_input.hwaccel_args or camera.ffmpeg.hwaccel_args, HwaccelKey + ) + ] += 1 + input_presets[ + preset_key( + detect_input.input_args or camera.ffmpeg.input_args, InputPresetKey + ) + ] += 1 + + if camera.detect.height: + heights[height_bucket(camera.detect.height)] += 1 + + state = camera_stats.get(name, {}).get("connection_quality") + + # a camera turned off at runtime reports unusable + if camera.enabled and state in QUALITIES: + quality[ConnectionQuality(state)] += 1 + + if camera.record.enabled: + retain["continuous"][retain_bucket(camera.record.continuous.days)] += 1 + retain["motion"][retain_bucket(camera.record.motion.days)] += 1 + retain["alerts"][retain_bucket(camera.record.alerts.retain.days)] += 1 + retain["detections"][ + retain_bucket(camera.record.detections.retain.days) + ] += 1 + + zones = len(camera.zones) + flags["go2rtc_restream"] += any( + is_restream(i.path) for i in camera.ffmpeg.inputs + ) + flags["separate_detect_stream"] += ( + record_input is not None and record_input.path != detect_input.path + ) + flags["detect"] += camera.detect.enabled + flags["record"] += camera.record.enabled + flags["sub_stream_record"] += ( + camera.record.enabled and camera.record.sub.enabled + ) + flags["snapshots"] += camera.snapshots.enabled + flags["audio"] += camera.audio.enabled + flags["audio_transcription"] += ( + camera.audio.enabled and camera.audio_transcription.enabled + ) + flags["birdseye"] += camera.birdseye.enabled + flags["onvif"] += bool(camera.onvif.host) + flags["autotracking"] += camera.onvif.autotracking.enabled + flags["face_recognition"] += camera.face_recognition.enabled + flags["lpr"] += camera.lpr.enabled + flags["review_genai"] += camera.review.genai.enabled + flags["object_genai"] += camera.objects.genai.enabled + flags["notifications"] += camera.notifications.enabled + flags["zones"] += zones + flags["cameras_with_zones"] += zones > 0 + flags["motion_masks"] += sum( + 1 for mask in camera.motion.mask.values() if mask is not None + ) + flags["object_masks"] += object_masks(camera) + + return CamerasSection( + total=len(cameras), + enabled=len(active), + types=histogram(types), + detect_height=histogram(heights), + detect_fps=histogram(fps), + hwaccel=histogram(hwaccel), + input_preset=histogram(input_presets), + go2rtc_restream=flags["go2rtc_restream"], + separate_detect_stream=flags["separate_detect_stream"], + detect=flags["detect"], + record=flags["record"], + sub_stream_record=flags["sub_stream_record"], + snapshots=flags["snapshots"], + audio=flags["audio"], + audio_transcription=flags["audio_transcription"], + birdseye=flags["birdseye"], + onvif=flags["onvif"], + autotracking=flags["autotracking"], + face_recognition=flags["face_recognition"], + lpr=flags["lpr"], + review_genai=flags["review_genai"], + object_genai=flags["object_genai"], + notifications=flags["notifications"], + zones=flags["zones"], + cameras_with_zones=flags["cameras_with_zones"], + motion_masks=flags["motion_masks"], + object_masks=flags["object_masks"], + connection_quality=histogram(quality), + retain_days=RetainDays( + continuous=histogram(retain["continuous"]), + motion=histogram(retain["motion"]), + alerts=histogram(retain["alerts"]), + detections=histogram(retain["detections"]), + ), + ) diff --git a/frigate/analytics/collectors/common.py b/frigate/analytics/collectors/common.py new file mode 100644 index 0000000000..8e4ff1cca2 --- /dev/null +++ b/frigate/analytics/collectors/common.py @@ -0,0 +1,37 @@ +"""Helpers the section collectors share.""" + +import math +from collections import Counter +from enum import Enum +from typing import Any, TypeVar + +E = TypeVar("E", bound=Enum) + + +def closed(enum: type[E], value: Any, fallback: E) -> E: + """The member for a value, or the fallback for one the enum doesn't know.""" + try: + return enum(str(value)) + except ValueError: + return fallback + + +def rate(value: Any) -> float: + """A finite, non-negative number rounded to 2 decimals, else 0. + + A NaN would serialize as null and fail the schema's number type. + """ + try: + number = float(value) + except (TypeError, ValueError): + return 0.0 + + if not math.isfinite(number): + return 0.0 + + return round(max(number, 0.0), 2) + + +def histogram(counter: "Counter[E]") -> dict[E, int]: + """Drop the empty buckets, since an absent key means zero.""" + return {key: total for key, total in counter.items() if total > 0} diff --git a/frigate/analytics/collectors/detection.py b/frigate/analytics/collectors/detection.py new file mode 100644 index 0000000000..f9155f38ec --- /dev/null +++ b/frigate/analytics/collectors/detection.py @@ -0,0 +1,69 @@ +"""Detection section: models, the detectors they run on, and inference speed.""" + +import os + +from frigate.analytics.collectors.common import rate +from frigate.analytics.context import ReportContext +from frigate.analytics.schema import DetectionModel, DetectionSection, ModelSource +from frigate.config.config import DEFAULT_MODEL +from frigate.const import MODEL_CACHE_DIR +from frigate.detectors.detector_config import DEFAULT_SCENE +from frigate.detectors.detector_types import DetectorTypeEnum +from frigate.detectors.device import runner_names + +# the paths FrigateConfig fills in for a model that sets none +BUNDLED_MODEL_PATHS = frozenset( + {"/cpu_model.tflite", "/edgetpu_model.tflite", str(DEFAULT_MODEL["path"])} +) + + +def model_source(path: str | None) -> ModelSource: + """Default, Frigate+ (a cached model next to its info file), or custom. + + Parsing rewrites plus:// to the model cache, so the prefix is gone by now. + """ + if path is None or path in BUNDLED_MODEL_PATHS: + return ModelSource.default + + if path.startswith(f"{MODEL_CACHE_DIR}/") and os.path.isfile(f"{path}.json"): + return ModelSource.plus + + return ModelSource.custom + + +def collect(ctx: ReportContext) -> DetectionSection: + """Describe each configured model and its measured inference speed.""" + config = ctx.config + detectors = ctx.stats.get("detectors", {}) + model_specs = [(model, config.devices_for_model(model)) for model in config.models] + + # FrigateApp.start_detectors names the processes in this same order + names = iter(runner_names([spec for _, specs in model_specs for spec in specs])) + models: list[DetectionModel] = [] + + for model, specs in model_specs: + speeds: list[float] = [] + + for _ in specs: + speed = detectors.get(next(names), {}).get("inference_speed") + + if isinstance(speed, int | float) and speed > 0: + speeds.append(float(speed)) + + models.append( + DetectionModel( + default=model.scene == DEFAULT_SCENE, + detector=DetectorTypeEnum(specs[0].detector), + devices=len(specs), + model_type=model.model_type, + input=f"{model.width}x{model.height}", + source=model_source(model.path), + inference_ms=rate(sum(speeds) / len(speeds)) if speeds else None, + ) + ) + + return DetectionSection( + models=models, + detection_fps=rate(ctx.stats.get("detection_fps")), + skipped_fps=rate(ctx.stats.get("skipped_fps")), + ) diff --git a/frigate/analytics/collectors/features.py b/frigate/analytics/collectors/features.py new file mode 100644 index 0000000000..1eca92af6f --- /dev/null +++ b/frigate/analytics/collectors/features.py @@ -0,0 +1,150 @@ +"""Features section: enrichments, GenAI, integrations, and users.""" + +from collections import Counter + +from frigate.analytics.collectors.common import closed, histogram +from frigate.analytics.context import ReportContext +from frigate.analytics.schema import ( + BirdseyeUsage, + ClassificationUsage, + EnrichmentDevice, + EnrichmentUsage, + FeaturesSection, + GenAIUsage, + SemanticSearchModel, + SemanticSearchUsage, + TranscriptionModel, + TranscriptionUsage, + UserRole, +) +from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum +from frigate.const import REPLAY_CAMERA_PREFIX +from frigate.models import User + +RUNTIME_DEVICES = { + "cpu": EnrichmentDevice.cpu, + "cuda": EnrichmentDevice.cuda, + "tensorrt": EnrichmentDevice.tensorrt, + "migraphx": EnrichmentDevice.migraphx, +} +OPENVINO_DEVICES = { + "cpu": EnrichmentDevice.openvino_cpu, + "gpu": EnrichmentDevice.openvino_gpu, + "npu": EnrichmentDevice.openvino_npu, +} + + +def enrichment_device(label: object) -> EnrichmentDevice | None: + """Map a runner's device label, like "CUDA" or "OpenVINO GPU.0,CPU".""" + if not isinstance(label, str) or not label: + return None + + runtime, _, target = label.partition(" ") + + if runtime == "OpenVINO": + # AUTO reports "(CPU)" while it warms up on the CPU + first = target.split(",")[0].split(".")[0].strip("() ").lower() + return OPENVINO_DEVICES.get(first, EnrichmentDevice.other) + + return RUNTIME_DEVICES.get(label.lower(), EnrichmentDevice.other) + + +def model_name(model: object) -> str | None: + """The model's config value as a string.""" + if model is None: + return None + + return str(getattr(model, "value", model)) + + +def semantic_model(model: object) -> SemanticSearchModel | None: + """A built-in Jina model, or genai for any GenAI provider name.""" + name = model_name(model) + + if name is None: + return None + + if name in ("jinav1", "jinav2"): + return SemanticSearchModel(name) + + return SemanticSearchModel.genai + + +def transcription_model(model: object) -> TranscriptionModel | None: + """Whisper, or genai for any GenAI provider name.""" + name = model_name(model) + + if name is None: + return None + + return TranscriptionModel.whisper if name == "whisper" else TranscriptionModel.genai + + +def users() -> dict[UserRole, int]: + """Count users by role, with any custom role counted as custom.""" + roles: Counter[UserRole] = Counter( + closed(UserRole, user.role, UserRole.custom) for user in User.select(User.role) + ) + return histogram(roles) + + +def collect(ctx: ReportContext) -> FeaturesSection: + """Report which enrichments and integrations are configured.""" + config = ctx.config + devices = ctx.stats.get("embeddings", {}).get("devices", {}) + cameras = [ + camera + for name, camera in config.cameras.items() + if not name.startswith(REPLAY_CAMERA_PREFIX) + ] + providers: Counter[GenAIProviderEnum] = Counter( + genai.provider for genai in config.genai.values() + ) + roles: Counter[GenAIRoleEnum] = Counter( + role for genai in config.genai.values() for role in genai.roles + ) + custom = list(config.classification.custom.values()) + + return FeaturesSection( + face_recognition=EnrichmentUsage( + enabled=config.face_recognition.enabled, + model_size=config.face_recognition.model_size, + device=enrichment_device(devices.get("face_recognition")), + ), + lpr=EnrichmentUsage( + enabled=config.lpr.enabled, + model_size=config.lpr.model_size, + device=enrichment_device(devices.get("lpr")), + ), + semantic_search=SemanticSearchUsage( + enabled=config.semantic_search.enabled, + model=semantic_model(config.semantic_search.model), + model_size=config.semantic_search.model_size, + device=enrichment_device(devices.get("semantic_search")), + triggers=sum(len(camera.semantic_search.triggers) for camera in cameras), + ), + audio_transcription=TranscriptionUsage( + enabled=config.audio_transcription.enabled, + model=transcription_model(config.audio_transcription.model), + model_size=config.audio_transcription.model_size, + ), + genai=GenAIUsage(providers=histogram(providers), roles=histogram(roles)), + classification_models=ClassificationUsage( + state=sum(1 for model in custom if model.state_config is not None), + object=sum(1 for model in custom if model.object_config is not None), + ), + birdseye=BirdseyeUsage( + enabled=config.birdseye.enabled, + modes=list(config.birdseye.modes), + restream=config.birdseye.restream, + ), + mqtt=config.mqtt.enabled, + notifications=config.notifications.enabled, + auth=config.auth.enabled, + proxy_auth=config.proxy.header_map.user is not None, + tls=config.tls.enabled, + users=users(), + camera_groups=len(config.camera_groups), + profiles=len(config.profiles), + plus_api_key=config.plus_api.is_active(), + ) diff --git a/frigate/analytics/collectors/hardware.py b/frigate/analytics/collectors/hardware.py new file mode 100644 index 0000000000..99037f3ac2 --- /dev/null +++ b/frigate/analytics/collectors/hardware.py @@ -0,0 +1,114 @@ +"""Hardware section: CPU, memory, GPUs, decode and detection hardware, storage.""" + +import os +from collections import Counter +from typing import Any + +import psutil + +from frigate.analytics.collectors.common import closed, histogram +from frigate.analytics.context import ReportContext +from frigate.analytics.schema import ( + DecodeFamily, + GpuInfo, + GpuVendor, + HardwareKey, + HardwareSection, + StorageInfo, +) +from frigate.const import RECORD_DIR +from frigate.detectors.hardware import hardware_prober +from frigate.util.hwaccel import hwaccel_options + +DEVICE_TREE_MODEL = "/proc/device-tree/model" +CPUINFO = "/proc/cpuinfo" + + +def cpu_model() -> str: + """The board model on ARM boards, else the CPU's model name.""" + try: + with open(DEVICE_TREE_MODEL) as f: + board = f.read().strip("\x00\n ") + + if board: + return board[:64] + except OSError: + pass + + try: + with open(CPUINFO) as f: + for line in f: + key, _, value = line.partition(":") + + if key.strip() == "model name" and value.strip(): + return value.strip()[:64] + except OSError: + pass + + return "unknown" + + +def gpus(stats: dict[str, Any]) -> list[GpuInfo]: + """GPUs with a stats entry, by vendor and capped name.""" + found: list[GpuInfo] = [] + + for name, entry in stats.get("gpu_usages", {}).items(): + vendor = entry.get("vendor") if isinstance(entry, dict) else None + found.append( + GpuInfo( + vendor=closed(GpuVendor, vendor, GpuVendor.other), + name=str(name)[:64], + ) + ) + + return found + + +def decode_families() -> list[DecodeFamily]: + """Hardware decode families the host supports.""" + _, available = hwaccel_options() + families = [ + closed(DecodeFamily, family.key, DecodeFamily.other) for family in available + ] + return list(dict.fromkeys(families)) + + +def detection_hardware() -> dict[HardwareKey, int]: + """Detection accelerators found by the hardware probe, by unit count.""" + units: Counter[HardwareKey] = Counter() + + for found in hardware_prober.probe(): + units[closed(HardwareKey, found.key, HardwareKey.other)] += found.count + + return histogram(units) + + +def storage(stats: dict[str, Any]) -> StorageInfo: + """Filesystem, size, and usage of the recordings volume. + + Stats report sizes in MB. + """ + entry = stats.get("service", {}).get("storage", {}).get(RECORD_DIR) or {} + total_mb = float(entry.get("total") or 0) + used_mb = float(entry.get("used") or 0) + + return StorageInfo( + record_fs=str(entry.get("mount_type") or "unknown")[:16], + record_total_gb=round(total_mb / 1024), + record_used_pct=min(round(used_mb / total_mb * 100), 100) + if total_mb > 0 + else 0, + ) + + +def collect(ctx: ReportContext) -> HardwareSection: + """Describe the host: CPU, memory, GPUs, decoders, detectors, storage.""" + return HardwareSection( + cpu_model=cpu_model(), + cpu_cores=os.cpu_count() or 0, + memory_gb=round(psutil.virtual_memory().total / 2**30), + gpus=gpus(ctx.stats), + decode_families=decode_families(), + detection_hardware=detection_hardware(), + storage=storage(ctx.stats), + ) diff --git a/frigate/analytics/collectors/health.py b/frigate/analytics/collectors/health.py new file mode 100644 index 0000000000..22d7501a81 --- /dev/null +++ b/frigate/analytics/collectors/health.py @@ -0,0 +1,63 @@ +"""Health section: uptime, CPU, enrichment speed, and notice counts.""" + +from typing import Any + +from frigate.analytics.collectors.common import rate +from frigate.analytics.context import ReportContext +from frigate.analytics.schema import ( + EnrichmentTiming, + HealthSection, + NoticeCounts, + NoticeKindKey, +) + +TIMING_STATS = { + EnrichmentTiming.face: "face_recognition_speed", + EnrichmentTiming.lpr: "plate_recognition_speed", + EnrichmentTiming.plate_detection: "yolov9_plate_detection_speed", + EnrichmentTiming.image_embedding: "image_embedding_speed", + EnrichmentTiming.text_embedding: "text_embedding_speed", + EnrichmentTiming.review_description: "review_description_speed", + EnrichmentTiming.object_description: "object_description_speed", +} +REPORTABLE_KINDS = frozenset(key.value for key in NoticeKindKey) + + +def notice_counts(notice_stats: list[dict[str, Any]]) -> dict[Any, NoticeCounts]: + """Counts since the install ID was created, per reportable kind.""" + counts: dict[Any, NoticeCounts] = {} + + for row in notice_stats: + if row["kind"] not in REPORTABLE_KINDS: + continue + + occurrences = max(row["occurrences"] - row["reported_occurrences"], 0) + acknowledgements = max( + row["acknowledgements"] - row["reported_acknowledgements"], 0 + ) + mutes = max(row["mutes"] - row["reported_mutes"], 0) + + if occurrences or acknowledgements or mutes: + counts[NoticeKindKey(row["kind"])] = NoticeCounts( + occurrences=occurrences, acknowledgements=acknowledgements, mutes=mutes + ) + + return counts + + +def collect(ctx: ReportContext) -> HealthSection: + """Read health figures from the latest stats and notice counts.""" + service = ctx.stats.get("service", {}) + embeddings = ctx.stats.get("embeddings", {}) + cpu = ctx.stats.get("cpu_usages", {}).get("frigate.full_system", {}).get("cpu") + timings = { + timing: rate(embeddings.get(key)) for timing, key in TIMING_STATS.items() + } + + return HealthSection( + uptime_hours=int(rate(service.get("uptime")) // 3600), + cpu_percent=min(round(rate(cpu)), 100), + enrichment_ms={timing: value for timing, value in timings.items() if value > 0}, + retention_unmet=bool(service.get("retention_unmet", False)), + notices=notice_counts(ctx.notice_stats), + ) diff --git a/frigate/analytics/collectors/install.py b/frigate/analytics/collectors/install.py new file mode 100644 index 0000000000..45be1324b5 --- /dev/null +++ b/frigate/analytics/collectors/install.py @@ -0,0 +1,66 @@ +"""Install section: version, image variant, install type, platform.""" + +import os +import platform +import re + +from frigate.analytics.collectors.common import closed +from frigate.analytics.context import ReportContext +from frigate.analytics.schema import Arch, ImageVariant, InstallSection, InstallType +from frigate.version import VERSION + +KERNEL_PATTERN = re.compile(r"^(\d{1,3})\.(\d{1,3})") + + +def image_variant(value: str | None) -> ImageVariant: + """The published image from the build-time FRIGATE_IMAGE_VARIANT, dev when unset.""" + if not value: + return ImageVariant.dev + + return closed(ImageVariant, value, ImageVariant.other) + + +def install_type() -> InstallType: + """How Frigate runs; the add-on is a container too, so it's checked first.""" + if os.path.isfile("/data/options.json"): + return InstallType.ha_addon + + if os.environ.get("KUBERNETES_SERVICE_HOST"): + return InstallType.kubernetes + + if os.path.exists("/run/.containerenv"): + return InstallType.podman + + if os.path.exists("/.dockerenv"): + return InstallType.docker + + return InstallType.unknown + + +def arch(machine: str) -> Arch: + """The CPU architecture for a platform.machine() value.""" + match machine.lower(): + case "x86_64" | "amd64": + return Arch.x86_64 + case "aarch64" | "arm64": + return Arch.aarch64 + case _: + return Arch.other + + +def kernel(release: str) -> str: + """The major.minor of a kernel release string, or unknown.""" + match = KERNEL_PATTERN.match(release) + return f"{match.group(1)}.{match.group(2)}" if match else "unknown" + + +def collect(ctx: ReportContext) -> InstallSection: + """Describe the Frigate version, image, install method, and platform.""" + return InstallSection( + version=VERSION[:32], + image_variant=image_variant(os.environ.get("FRIGATE_IMAGE_VARIANT")), + install_type=install_type(), + arch=arch(platform.machine()), + kernel=kernel(platform.release()), + run_as_root=os.geteuid() == 0, + ) diff --git a/frigate/analytics/context.py b/frigate/analytics/context.py new file mode 100644 index 0000000000..ad6fdc6339 --- /dev/null +++ b/frigate/analytics/context.py @@ -0,0 +1,13 @@ +"""What the collectors read, gathered once per report.""" + +from dataclasses import dataclass, field +from typing import Any + +from frigate.config import FrigateConfig + + +@dataclass(frozen=True) +class ReportContext: + config: FrigateConfig + stats: dict[str, Any] + notice_stats: list[dict[str, Any]] = field(default_factory=list) diff --git a/frigate/analytics/report.py b/frigate/analytics/report.py new file mode 100644 index 0000000000..c173feae90 --- /dev/null +++ b/frigate/analytics/report.py @@ -0,0 +1,91 @@ +"""Build a report from the section collectors.""" + +import logging +import time +from collections.abc import Callable +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from pydantic import BaseModel + +from frigate.analytics.collectors import ( + cameras, + detection, + features, + hardware, + health, + install, +) +from frigate.analytics.context import ReportContext +from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport +from frigate.analytics.state import load_state +from frigate.config import FrigateConfig + +if TYPE_CHECKING: + from frigate.notices.registry import NoticeRegistry + from frigate.stats.emitter import StatsEmitter + +logger = logging.getLogger(__name__) + +COLLECTORS: dict[str, Callable[[ReportContext], BaseModel | None]] = { + "install": install.collect, + "hardware": hardware.collect, + "detection": detection.collect, + "cameras": cameras.collect, + "features": features.collect, + "health": health.collect, +} + +# shown in the preview until the first report creates a real ID +PREVIEW_INSTALL_ID = "0" * 32 + + +def build_report( + ctx: ReportContext, install_id: str, sent_at: int | None = None +) -> AnalyticsReport: + """Run every collector; one that fails sends its section as null.""" + sections: dict[str, Any] = {} + + for name, collect in COLLECTORS.items(): + try: + sections[name] = collect(ctx) + except Exception: + # a collector bug must cost one section, never the whole report + logger.debug("Analytics %s section failed", name, exc_info=True) + sections[name] = None + + return AnalyticsReport.model_validate( + { + "schema_version": SCHEMA_VERSION, + "install_id": install_id, + "report_id": str(uuid4()), + "sent_at": int(time.time()) if sent_at is None else sent_at, + **sections, + } + ) + + +def gather_context( + config: FrigateConfig, + stats_emitter: "StatsEmitter | None", + notice_registry: "NoticeRegistry | None", +) -> ReportContext: + """Snapshot the config, latest stats, and notice counts for one report.""" + return ReportContext( + config=config, + stats=stats_emitter.get_latest_stats() if stats_emitter is not None else {}, + notice_stats=notice_registry.stats() if notice_registry is not None else [], + ) + + +def preview_report( + config: FrigateConfig, + stats_emitter: "StatsEmitter | None", + notice_registry: "NoticeRegistry | None", +) -> AnalyticsReport: + """The report the next send would carry, without sending it.""" + state = load_state() + + # an opt-in resets the baseline, so before one there are no notices to show + ctx = gather_context(config, stats_emitter, notice_registry if state else None) + return build_report(ctx, state.install_id if state else PREVIEW_INSTALL_ID) diff --git a/frigate/analytics/reporter.py b/frigate/analytics/reporter.py new file mode 100644 index 0000000000..bc4554d9bc --- /dev/null +++ b/frigate/analytics/reporter.py @@ -0,0 +1,218 @@ +"""Send one analytics report a day while the admin has opted in.""" + +import logging +import random +import threading +import time +from collections.abc import Callable +from multiprocessing.synchronize import Event as MpEvent +from typing import TYPE_CHECKING + +from pydantic import ValidationError +from ruamel.yaml import YAML +from ruamel.yaml.error import YAMLError + +from frigate.analytics.report import build_report, gather_context +from frigate.analytics.state import ( + STATE_PATH, + AnalyticsState, + delete_state, + load_state, + new_state, + save_state, +) +from frigate.analytics.transport import SendOutcome, send_report +from frigate.config import FrigateConfig +from frigate.config.holder import ConfigHolder +from frigate.config.telemetry import TelemetryConfig +from frigate.const import ANALYTICS_URL +from frigate.notices import raise_notice, resolve_notice +from frigate.util.config import find_config_file + +if TYPE_CHECKING: + from frigate.notices.registry import NoticeRegistry + from frigate.stats.emitter import StatsEmitter + +logger = logging.getLogger(__name__) + +WAKE_S = 10 * 60 +INTERVAL_S = 24 * 60 * 60 +JITTER_S = 60 * 60 +FIRST_DELAY_S = (15 * 60, 45 * 60) +PROMPT_KIND = "analytics_prompt" + + +def saved_consent(path: str) -> bool | None: + """telemetry.analytics as saved in the config file, or None if unreadable.""" + try: + with open(path) as f: + raw = YAML(typ="safe").load(f) + except (OSError, ValueError, YAMLError): + return None + + if not isinstance(raw, dict): + return None + + # the live config's model, so yes, on, and 1 read as consent there too + try: + return TelemetryConfig.model_validate(raw.get("telemetry") or {}).analytics + except ValidationError: + return None + + +class AnalyticsReporter(threading.Thread): + """Follows the live config, so a settings save needs no restart.""" + + def __init__( + self, + config_holder: ConfigHolder, + stats_emitter: "StatsEmitter", + notice_registry: "NoticeRegistry", + stop_event: MpEvent | threading.Event, + *, + state_path: str = STATE_PATH, + config_path: str | None = None, + url: str = ANALYTICS_URL, + send: Callable[[str, str], SendOutcome] = send_report, + clock: Callable[[], float] = time.time, + rng: random.Random | None = None, + ) -> None: + super().__init__(name="analytics_reporter", daemon=True) + self.config_holder = config_holder + self.stats_emitter = stats_emitter + self.notice_registry = notice_registry + self.stop_event = stop_event + self.state_path = state_path + self.config_path = config_path or find_config_file() + self.url = url + self.send = send + self.clock = clock + self.rng = rng or random.Random() + self.first_due = clock() + self.rng.uniform(*FIRST_DELAY_S) + self.interval = self._next_interval() + self.opted_in: bool | None = None + self.warned_unwritable = False + # a settings save runs on an API thread while a wake may be mid-attempt + self._lock = threading.Lock() + config_holder.subscribe(self._on_config) + + def _next_interval(self) -> float: + return INTERVAL_S + self.rng.uniform(-JITTER_S, JITTER_S) + + def run(self) -> None: + """Tick every wake until stopped; a failed tick never ends the thread.""" + while True: + try: + self.tick() + except Exception: + logger.exception("Analytics reporter failed") + + if self.stop_event.wait(WAKE_S): + break + + def _on_config(self, config: FrigateConfig) -> None: + # a save can turn sharing off and back on between two wakes, so an + # opt-out is handled when it's saved rather than at the next wake + with self._lock: + if not config.safe_mode: + self._apply_consent(config.telemetry.analytics) + + def _apply_consent(self, opted_in: bool) -> None: + # called with the lock held + if opted_in == self.opted_in: + return + + self.opted_in = opted_in + + if opted_in: + resolve_notice(PROMPT_KIND) + + # the opt-in sets the notice baseline, so only notices raised with + # consent are counted; tick() retries a failed write + if load_state(self.state_path) is None: + save_state(self._new_identity(), self.state_path) + else: + raise_notice(PROMPT_KIND) + delete_state(self.state_path) + + def _new_identity(self) -> AnalyticsState: + # called with the lock held; the baseline resets before the state file + # names the new ID, so a failed write only means another reset + self.notice_registry.reset_analytics_baseline() + return new_state() + + def tick(self) -> None: + """Apply consent and send a report when one is due.""" + with self._lock: + config = self.config_holder.config + + # safe mode parses a default config where analytics reads as off, + # and handling that as an opt-out would delete the install ID + if config.safe_mode: + return + + self._apply_consent(config.telemetry.analytics) + + if not config.telemetry.analytics: + return + + now = self.clock() + + if now < self.first_due: + return + + state = load_state(self.state_path) + + if state is not None and not self._due(state.last_attempt_at, now): + return + + # a YAML editor save or a hand edit reaches the live config only on + # restart, so an opt-out saved that way is read from the file + if saved_consent(self.config_path) is False: + delete_state(self.state_path) + return + + state = AnalyticsState( + (state or self._new_identity()).install_id, last_attempt_at=now + ) + + # saved before sending, so a failing endpoint or a crash mid-send + # still waits a full interval; without saved state every boot would + # send under a new install ID + if not save_state(state, self.state_path): + if not self.warned_unwritable: + logger.warning( + "Analytics is on, but %s isn't writable, so no report is sent", + self.state_path, + ) + self.warned_unwritable = True + + return + + self.interval = self._next_interval() + + # the lock stays free during the request, so a save never waits on it + self._send(state.install_id, now) + + def _due(self, last_attempt_at: float, now: float) -> bool: + # a last attempt stamped in the future came from a wrong clock + return ( + now >= last_attempt_at + self.interval or last_attempt_at > now + INTERVAL_S + ) + + def _send(self, install_id: str, now: float) -> None: + ctx = gather_context( + self.config_holder.config, self.stats_emitter, self.notice_registry + ) + report = build_report(ctx, install_id, sent_at=int(now)) + body = report.model_dump_json() + + # consent can be withdrawn while the report builds + if not self.config_holder.config.telemetry.analytics: + return + + if self.send(self.url, body) is not SendOutcome.accepted: + return + + logger.info("Sent the daily analytics report") + logger.debug("Analytics report: %s", body) diff --git a/frigate/analytics/schema.py b/frigate/analytics/schema.py new file mode 100644 index 0000000000..6ad59c2dbe --- /dev/null +++ b/frigate/analytics/schema.py @@ -0,0 +1,419 @@ +"""Models for the analytics report, the contract with the ingest endpoint. + +Every field carries a description and an x-public flag, and no field accepts +user-entered text, so a report can't carry camera names or other free text. +""" + +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from frigate.config.camera.birdseye import BirdseyeModeEnum +from frigate.config.camera.camera import CameraTypeEnum +from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum +from frigate.config.classification import ModelSizeEnum +from frigate.detectors.detector_config import ModelTypeEnum +from frigate.detectors.detector_types import DetectorTypeEnum +from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT +from frigate.notices.types import NOTICE_KINDS + +SCHEMA_VERSION = 1 + + +class ImageVariant(StrEnum): + standard = "standard" + rpi = "rpi" + tensorrt = "tensorrt" + tensorrt_jp6 = "tensorrt-jp6" + rocm = "rocm" + rk = "rk" + synaptics = "synaptics" + dev = "dev" + other = "other" + + +class InstallType(StrEnum): + ha_addon = "ha_addon" + docker = "docker" + podman = "podman" + kubernetes = "kubernetes" + unknown = "unknown" + + +class Arch(StrEnum): + x86_64 = "x86_64" + aarch64 = "aarch64" + other = "other" + + +class GpuVendor(StrEnum): + intel = "intel" + amd = "amd" + nvidia = "nvidia" + rockchip = "rockchip" + rpi = "rpi" + apple_silicon = "apple-silicon" + other = "other" + + +class DecodeFamily(StrEnum): + nvidia = "nvidia" + vaapi = "vaapi" + rkmpp = "rkmpp" + intel_qsv = "intel-qsv" + jetson = "jetson" + rpi = "rpi" + apple_silicon = "apple-silicon" + other = "other" + + +class HardwareKey(StrEnum): + edgetpu_pci = "edgetpu:pci" + edgetpu_usb = "edgetpu:usb" + openvino_gpu = "openvino:GPU" + openvino_npu = "openvino:NPU" + onnx_amd = "onnx:amd" + onnx_nvidia = "onnx:nvidia" + onnx_lighter = "onnx:lighter" + tensorrt = "tensorrt" + hailo = "hailo" + memryx = "memryx" + deepx = "deepx" + rknn = "rknn" + axengine = "axengine" + axelera = "axelera" + synaptics = "synaptics" + cpu = "cpu" + other = "other" + + +class ModelSource(StrEnum): + default = "default" + plus = "plus" + custom = "custom" + + +class HeightBucket(StrEnum): + le_360 = "le_360" + h480 = "480" + h720 = "720" + h1080 = "1080" + h1440 = "1440" + ge_2160 = "ge_2160" + + +class FpsBucket(StrEnum): + le_5 = "le_5" + f6_10 = "6_10" + gt_10 = "gt_10" + + +class RetainBucket(StrEnum): + zero = "0" + d1_7 = "1_7" + d8_30 = "8_30" + gt_30 = "gt_30" + + +class ConnectionQuality(StrEnum): + excellent = "excellent" + fair = "fair" + poor = "poor" + unusable = "unusable" + + +class EnrichmentDevice(StrEnum): + cpu = "cpu" + cuda = "cuda" + tensorrt = "tensorrt" + migraphx = "migraphx" + openvino_cpu = "openvino_cpu" + openvino_gpu = "openvino_gpu" + openvino_npu = "openvino_npu" + other = "other" + + +class SemanticSearchModel(StrEnum): + jinav1 = "jinav1" + jinav2 = "jinav2" + genai = "genai" + + +class TranscriptionModel(StrEnum): + whisper = "whisper" + genai = "genai" + + +class UserRole(StrEnum): + admin = "admin" + viewer = "viewer" + custom = "custom" + + +class EnrichmentTiming(StrEnum): + face = "face" + lpr = "lpr" + plate_detection = "plate_detection" + image_embedding = "image_embedding" + text_embedding = "text_embedding" + review_description = "review_description" + object_description = "object_description" + + +def _preset_keys(presets: dict[str, Any]) -> dict[str, str]: + keys = [name.removeprefix("preset-") for name in presets] + return {key: key for key in [*keys, "custom", "none"]} + + +# built from the registries they mirror, so a new preset or notice kind reaches +# the schema through generate_analytics_schema.py instead of a hand edit +HwaccelKey = StrEnum("HwaccelKey", _preset_keys(PRESETS_HW_ACCEL_DECODE)) # type: ignore[misc] +InputPresetKey = StrEnum("InputPresetKey", _preset_keys(PRESETS_INPUT)) # type: ignore[misc] +NoticeKindKey = StrEnum( # type: ignore[misc] + "NoticeKindKey", + {key: key for key, kind in NOTICE_KINDS.items() if kind.reportable}, +) + + +class AnalyticsModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +def metric(description: str, *, public: bool = True, **kwargs: Any) -> Any: + """Declare a report field; the description and flag land in the schema.""" + return Field( + description=description, json_schema_extra={"x-public": public}, **kwargs + ) + + +def count(description: str) -> Any: + """Declare a non-negative count field.""" + return metric(description, ge=0) + + +class InstallSection(AnalyticsModel): + version: str = metric("Frigate version string", max_length=32) + image_variant: ImageVariant = metric("Published image the install runs") + install_type: InstallType = metric("How Frigate is installed") + arch: Arch = metric("CPU architecture") + kernel: str = metric( + "Host kernel as major.minor", pattern=r"^(\d{1,3}\.\d{1,3}|unknown)$" + ) + run_as_root: bool = metric("Whether the main process runs as root") + + +class GpuInfo(AnalyticsModel): + vendor: GpuVendor = metric("GPU vendor") + name: str = metric("GPU name as the hardware reports it", max_length=64) + + +class StorageInfo(AnalyticsModel): + record_fs: str = metric("Filesystem of the recordings volume", max_length=16) + record_total_gb: int = count("Size of the recordings volume in GB") + record_used_pct: int = metric( + "Percent of the recordings volume in use", ge=0, le=100 + ) + + +class HardwareSection(AnalyticsModel): + cpu_model: str = metric( + "CPU or board model as the hardware reports it", max_length=64 + ) + cpu_cores: int = count("Logical CPU count") + memory_gb: int = count("Total memory in GB") + gpus: list[GpuInfo] = metric("GPUs the stats collector found") + decode_families: list[DecodeFamily] = metric( + "Hardware decode families this system can use" + ) + detection_hardware: dict[HardwareKey, int] = metric( + "Detection hardware found, as unit counts by kind" + ) + storage: StorageInfo = metric("Recordings storage") + + +class DetectionModel(AnalyticsModel): + default: bool = metric("Whether the model serves cameras that set no scene") + detector: DetectorTypeEnum = metric("Detector type the model runs on") + devices: int = count("Devices the model runs on") + model_type: ModelTypeEnum = metric("Model architecture") + input: str = metric("Model input size as WxH", pattern=r"^\d{1,5}x\d{1,5}$") + source: ModelSource = metric("Where the model came from", public=False) + inference_ms: float | None = metric( + "Mean inference time across the model's detector processes, null before the first stats", + ge=0, + ) + + +class DetectionSection(AnalyticsModel): + models: list[DetectionModel] = metric("Detection models") + detection_fps: float = metric("Detections per second across cameras", ge=0) + skipped_fps: float = metric("Frames per second skipped across cameras", ge=0) + + +class RetainDays(AnalyticsModel): + continuous: dict[RetainBucket, int] = metric( + "Recording cameras by continuous retention in days" + ) + motion: dict[RetainBucket, int] = metric( + "Recording cameras by motion retention in days" + ) + alerts: dict[RetainBucket, int] = metric( + "Recording cameras by alert retention in days" + ) + detections: dict[RetainBucket, int] = metric( + "Recording cameras by detection retention in days" + ) + + +class CamerasSection(AnalyticsModel): + total: int = count("Configured cameras") + enabled: int = count( + "Cameras enabled in config; every count below covers only these" + ) + types: dict[CameraTypeEnum, int] = metric("Cameras by type") + detect_height: dict[HeightBucket, int] = metric( + "Cameras by detect resolution height" + ) + detect_fps: dict[FpsBucket, int] = metric("Cameras by detect fps") + hwaccel: dict[HwaccelKey, int] = metric( + "Cameras by the resolved hwaccel preset of the detect input" + ) + input_preset: dict[InputPresetKey, int] = metric( + "Cameras by the input preset of the detect input" + ) + go2rtc_restream: int = count("Cameras with an input from the go2rtc restream") + separate_detect_stream: int = count( + "Cameras whose detect input differs from their record input" + ) + detect: int = count("Cameras with detection on") + record: int = count("Cameras with recording on") + sub_stream_record: int = count("Cameras with sub stream recording on") + snapshots: int = count("Cameras with snapshots on") + audio: int = count("Cameras with audio detection on") + audio_transcription: int = count( + "Cameras with audio detection and transcription on" + ) + birdseye: int = count("Cameras in birdseye") + onvif: int = count("Cameras with an ONVIF host") + autotracking: int = count("Cameras with PTZ autotracking on") + face_recognition: int = count("Cameras with face recognition on") + lpr: int = count("Cameras with license plate recognition on") + review_genai: int = count("Cameras with GenAI review summaries on") + object_genai: int = count("Cameras with GenAI object descriptions on") + notifications: int = count("Cameras with notifications on") + zones: int = count("Zones across cameras") + cameras_with_zones: int = count("Cameras with at least one zone") + motion_masks: int = count("Motion masks across cameras") + object_masks: int = count("Object masks across cameras") + connection_quality: dict[ConnectionQuality, int] = metric( + "Cameras by connection quality at send time" + ) + retain_days: RetainDays = metric("Recording retention") + + +class EnrichmentUsage(AnalyticsModel): + enabled: bool = metric("Whether the enrichment is on") + model_size: ModelSizeEnum = metric("Configured model size") + device: EnrichmentDevice | None = metric( + "Device the model loaded on, null when it isn't loaded" + ) + + +class SemanticSearchUsage(AnalyticsModel): + enabled: bool = metric("Whether semantic search is on") + model: SemanticSearchModel | None = metric( + "Embedding model, genai for a GenAI provider" + ) + model_size: ModelSizeEnum = metric("Configured model size") + device: EnrichmentDevice | None = metric( + "Device the model loaded on, null when it isn't loaded" + ) + triggers: int = count("Semantic search triggers across cameras") + + +class TranscriptionUsage(AnalyticsModel): + enabled: bool = metric("Whether audio transcription is on") + model: TranscriptionModel | None = metric( + "Transcription model, genai for a GenAI provider" + ) + model_size: ModelSizeEnum = metric("Configured model size") + + +class GenAIUsage(AnalyticsModel): + providers: dict[GenAIProviderEnum, int] = metric( + "Configured GenAI providers by type" + ) + roles: dict[GenAIRoleEnum, int] = metric("Configured GenAI providers by role") + + +class ClassificationUsage(AnalyticsModel): + state: int = count("Custom state classification models") + object: int = count("Custom object classification models") + + +class BirdseyeUsage(AnalyticsModel): + enabled: bool = metric("Whether birdseye is on") + modes: list[BirdseyeModeEnum] = metric("Birdseye modes") + restream: bool = metric("Whether birdseye is restreamed") + + +class FeaturesSection(AnalyticsModel): + face_recognition: EnrichmentUsage = metric("Face recognition") + lpr: EnrichmentUsage = metric("License plate recognition") + semantic_search: SemanticSearchUsage = metric("Semantic search") + audio_transcription: TranscriptionUsage = metric("Audio transcription") + genai: GenAIUsage = metric("Generative AI providers") + classification_models: ClassificationUsage = metric("Custom classification models") + birdseye: BirdseyeUsage = metric("Birdseye") + mqtt: bool = metric("Whether MQTT is on") + notifications: bool = metric("Whether web push notifications are on") + auth: bool = metric("Whether authentication is on") + proxy_auth: bool = metric("Whether a proxy supplies the user header") + tls: bool = metric("Whether TLS is on") + users: dict[UserRole, int] = metric("Users by role") + camera_groups: int = count("Camera groups") + profiles: int = count("Profiles") + plus_api_key: bool = metric("Whether a Frigate+ API key is set", public=False) + + +class NoticeCounts(AnalyticsModel): + occurrences: int = count("Occurrences since the install ID was created") + acknowledgements: int = count("Acknowledgements since the install ID was created") + mutes: int = count("Mutes since the install ID was created") + + +class HealthSection(AnalyticsModel): + uptime_hours: int = count("Hours since Frigate started") + cpu_percent: int = metric("System CPU use at send time", ge=0, le=100) + enrichment_ms: dict[EnrichmentTiming, float] = metric( + "Mean enrichment inference times in milliseconds" + ) + retention_unmet: bool = metric( + "Whether storage can't keep the configured retention" + ) + notices: dict[NoticeKindKey, NoticeCounts] = metric( + "Cumulative notice counts by kind since the install ID was created", + public=False, + ) + + +class AnalyticsReport(AnalyticsModel): + schema_version: int = metric("Report format version", ge=1) + install_id: str = metric( + "Random install identifier", public=False, pattern=r"^[0-9a-f]{32}$" + ) + report_id: str = metric( + "Random identifier of this report", + public=False, + pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + ) + sent_at: int = count("Unix time the report was built") + install: InstallSection | None = metric("Install, null if its collector failed") + hardware: HardwareSection | None = metric("Hardware, null if its collector failed") + detection: DetectionSection | None = metric( + "Object detection, null if its collector failed" + ) + cameras: CamerasSection | None = metric("Cameras, null if its collector failed") + features: FeaturesSection | None = metric("Features, null if its collector failed") + health: HealthSection | None = metric("Health, null if its collector failed") diff --git a/frigate/analytics/state.py b/frigate/analytics/state.py new file mode 100644 index 0000000000..9e3154d4ec --- /dev/null +++ b/frigate/analytics/state.py @@ -0,0 +1,86 @@ +"""The install's analytics identity and last attempt time, kept under /config.""" + +import json +import logging +import os +import re +from dataclasses import dataclass +from uuid import uuid4 + +from frigate.const import CONFIG_DIR + +logger = logging.getLogger(__name__) + +STATE_PATH = os.path.join(CONFIG_DIR, ".analytics.json") +INSTALL_ID_PATTERN = re.compile(r"[0-9a-f]{32}") + + +@dataclass(frozen=True) +class AnalyticsState: + install_id: str + last_attempt_at: float + + +def new_state() -> AnalyticsState: + """A fresh install ID that has never attempted a report.""" + return AnalyticsState(install_id=uuid4().hex, last_attempt_at=0.0) + + +def load_state(path: str = STATE_PATH) -> AnalyticsState | None: + """The saved state, or None when the file is missing or unusable.""" + try: + with open(path) as f: + data = json.load(f) + except FileNotFoundError: + return None + except (OSError, ValueError): + logger.debug("Ignoring unreadable analytics state at %s", path) + return None + + if not isinstance(data, dict): + return None + + install_id = data.get("install_id") + last_attempt_at = data.get("last_attempt_at") + + if ( + not isinstance(install_id, str) + or not INSTALL_ID_PATTERN.fullmatch(install_id) + or isinstance(last_attempt_at, bool) + or not isinstance(last_attempt_at, int | float) + ): + logger.debug("Ignoring invalid analytics state at %s", path) + return None + + return AnalyticsState(install_id=install_id, last_attempt_at=float(last_attempt_at)) + + +def save_state(state: AnalyticsState, path: str = STATE_PATH) -> bool: + """Write the state atomically, returning False when it can't be written.""" + temp_path = f"{path}.tmp" + + try: + with open(temp_path, "w") as f: + json.dump( + { + "install_id": state.install_id, + "last_attempt_at": state.last_attempt_at, + }, + f, + ) + + os.replace(temp_path, path) + except OSError: + return False + + return True + + +def delete_state(path: str = STATE_PATH) -> None: + """Forget the install ID, so a later opt-in starts a fresh identity.""" + try: + os.remove(path) + except FileNotFoundError: + pass + except OSError: + logger.warning("Unable to delete analytics state at %s", path) diff --git a/frigate/analytics/transport.py b/frigate/analytics/transport.py new file mode 100644 index 0000000000..f672940857 --- /dev/null +++ b/frigate/analytics/transport.py @@ -0,0 +1,54 @@ +"""POST a report to the ingest endpoint.""" + +import logging +from enum import Enum + +import requests + +from frigate.version import VERSION + +logger = logging.getLogger(__name__) + +TIMEOUT_S = 30 + + +class SendOutcome(Enum): + accepted = "accepted" + rejected = "rejected" + rate_limited = "rate_limited" + failed = "failed" + + +def send_report(url: str, body: str) -> SendOutcome: + """Send one report. Never raises; the outcome says what happened.""" + try: + # a redirect would turn the POST into a GET, so it counts as a failure + response = requests.post( + url, + data=body.encode(), + headers={ + "Content-Type": "application/json", + "User-Agent": f"Frigate/{VERSION}", + }, + timeout=TIMEOUT_S, + allow_redirects=False, + ) + except requests.RequestException as err: + logger.debug("Unable to send the analytics report: %s", err) + return SendOutcome.failed + + status = response.status_code + + if 200 <= status < 300: + return SendOutcome.accepted + + if status == 400: + logger.debug("The analytics report was rejected: %s", response.text[:200]) + return SendOutcome.rejected + + if status == 429: + logger.debug("The analytics endpoint is rate limiting this install") + return SendOutcome.rate_limited + + logger.debug("The analytics endpoint returned %s", status) + return SendOutcome.failed diff --git a/frigate/api/analytics.py b/frigate/api/analytics.py new file mode 100644 index 0000000000..a05daca436 --- /dev/null +++ b/frigate/api/analytics.py @@ -0,0 +1,25 @@ +"""Analytics APIs.""" + +import logging + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse + +from frigate.analytics.report import preview_report +from frigate.api.auth import require_role +from frigate.api.defs.tags import Tags + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=[Tags.analytics]) + + +@router.get("/analytics/preview", dependencies=[Depends(require_role(["admin"]))]) +def get_analytics_preview(request: Request) -> JSONResponse: + """Get the analytics report Frigate would send next, without sending it.""" + report = preview_report( + request.app.frigate_config, + request.app.stats_emitter, + request.app.notice_registry, + ) + return JSONResponse(content=report.model_dump(mode="json")) diff --git a/frigate/api/defs/tags.py b/frigate/api/defs/tags.py index 9ce733329c..d2d2e2af7a 100644 --- a/frigate/api/defs/tags.py +++ b/frigate/api/defs/tags.py @@ -2,6 +2,7 @@ from enum import Enum class Tags(Enum): + analytics = "Analytics" app = "App" auth = "Auth" camera = "Camera" diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 3d952c9da6..fbc4da7fed 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -13,8 +13,8 @@ from slowapi.middleware import SlowAPIMiddleware from starlette_context import middleware, plugins from starlette_context.plugins import Plugin -from frigate.api import app as main_app from frigate.api import ( + analytics, auth, camera, chat, @@ -31,6 +31,7 @@ from frigate.api import ( record, review, ) +from frigate.api import app as main_app from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default from frigate.comms.dispatcher import Dispatcher from frigate.comms.event_metadata_updater import ( @@ -143,6 +144,7 @@ def create_fastapi_app( # Routes # Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters + app.include_router(analytics.router) app.include_router(auth.router) app.include_router(camera.router) app.include_router(chat.router) diff --git a/frigate/app.py b/frigate/app.py index 7c7b152696..36e409fb3d 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -15,6 +15,7 @@ import uvicorn from peewee import SqliteDatabase from peewee_migrate import Router +from frigate.analytics.reporter import AnalyticsReporter from frigate.api.auth import hash_password from frigate.api.fastapi_app import create_fastapi_app from frigate.camera import CameraMetrics, PTZMetrics @@ -524,6 +525,15 @@ class FrigateApp: ) self.stats_emitter.start() + def start_analytics_reporter(self) -> None: + self.analytics_reporter = AnalyticsReporter( + self.config_holder, + self.stats_emitter, + self.notice_registry, + self.stop_event, + ) + self.analytics_reporter.start() + def start_watchdog(self) -> None: self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event) @@ -697,6 +707,7 @@ class FrigateApp: self.start_audio_processor() self.start_storage_maintainer() self.start_stats_emitter() + self.start_analytics_reporter() self.start_timeline_processor() self.start_event_processor() self.start_event_cleanup() @@ -798,6 +809,8 @@ class FrigateApp: self.event_cleanup.join() self.record_cleanup.join() self.stats_emitter.join() + # a send in flight can hold the thread for the whole request timeout + self.analytics_reporter.join(timeout=5) self.frigate_watchdog.join() self.camera_maintainer.join() self.db.stop() diff --git a/frigate/config/holder.py b/frigate/config/holder.py index e5f3e7bd8f..26efec733f 100644 --- a/frigate/config/holder.py +++ b/frigate/config/holder.py @@ -1,9 +1,14 @@ """Shared handle on the config object that is current for this instance.""" +import logging +from collections.abc import Callable + from .config import FrigateConfig __all__ = ["ConfigHolder"] +logger = logging.getLogger(__name__) + class ConfigHolder: """Indirection for the most recently parsed config. @@ -23,12 +28,24 @@ class ConfigHolder: def __init__(self, config: FrigateConfig) -> None: self._config = config + self._listeners: list[Callable[[FrigateConfig], None]] = [] @property def config(self) -> FrigateConfig: """The config as of the most recent successful save.""" return self._config + def subscribe(self, listener: Callable[[FrigateConfig], None]) -> None: + """Call listener on the saving thread with each config installed later.""" + self._listeners.append(listener) + def set(self, config: FrigateConfig) -> None: """Install a freshly parsed config as the current one.""" self._config = config + + for listener in self._listeners: + try: + listener(config) + except Exception: + # a listener bug must not fail the save that has already applied + logger.exception("Config listener failed") diff --git a/frigate/config/telemetry.py b/frigate/config/telemetry.py index 3c219d7460..4b2ed4277d 100644 --- a/frigate/config/telemetry.py +++ b/frigate/config/telemetry.py @@ -29,6 +29,11 @@ class StatsConfig(FrigateBaseModel): class TelemetryConfig(FrigateBaseModel): + analytics: bool = Field( + default=False, + title="Share anonymous analytics", + description="Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on.", + ) network_interfaces: list[str] = Field( default=[], title="Network interfaces", diff --git a/frigate/const.py b/frigate/const.py index 6f4a8470ae..7fa145734f 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -102,6 +102,9 @@ MAX_WAL_SIZE = 10 # MB DEFAULT_FFMPEG_VERSION = os.environ.get("DEFAULT_FFMPEG_VERSION", "") INCLUDED_FFMPEG_VERSIONS = os.environ.get("INCLUDED_FFMPEG_VERSIONS", "").split(":") +ANALYTICS_URL = os.environ.get( + "FRIGATE_ANALYTICS_URL", "https://analytics.frigate.video/report" +) LIBAVFORMAT_VERSION_MAJOR = int(os.environ.get("LIBAVFORMAT_VERSION_MAJOR", "59")) FFMPEG_HWACCEL_NVIDIA = "preset-nvidia" FFMPEG_HWACCEL_VAAPI = "preset-vaapi" diff --git a/frigate/models.py b/frigate/models.py index 8d813d3a66..b816bde550 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -209,7 +209,7 @@ class NoticeStats(Model): mutes = IntegerField(default=0) first_seen = DateTimeField() last_seen = DateTimeField() - # watermarks for a future analytics reporter; unused until then + # lifetime counts when the current analytics install ID was created reported_occurrences = IntegerField(default=0) reported_acknowledgements = IntegerField(default=0) reported_mutes = IntegerField(default=0) diff --git a/frigate/notices/registry.py b/frigate/notices/registry.py index a3e0d4cd78..f55ab2f76c 100644 --- a/frigate/notices/registry.py +++ b/frigate/notices/registry.py @@ -132,24 +132,7 @@ class NoticeRegistry: def flush(self) -> None: """Write the repeats that batch_repeats kinds held back.""" with self._lock: - held, self._held = self._held, {} - written = False - - for row_id, repeats in held.items(): - row = Notice.get_or_none(Notice.id == row_id) - - # resolved while its repeats waited - if row is None: - continue - - self._write_repeats( - row, - repeats.kind, - repeats.count, - repeats.last_seen, - repeats.params, - ) - written = True + written = self._write_held() if written: self._notify() @@ -385,6 +368,43 @@ class NoticeRegistry: if row.kind in NOTICE_KINDS ] + def reset_analytics_baseline(self) -> None: + """Count analytics notices from now on, for a new analytics install ID.""" + with self._lock: + # held repeats were raised before the reset, so they join the baseline + written = self._write_held() + NoticeStats.update( + reported_occurrences=NoticeStats.occurrences, + reported_acknowledgements=NoticeStats.acknowledgements, + reported_mutes=NoticeStats.mutes, + ).execute() + + if written: + self._notify() + + def _write_held(self) -> bool: + # called with the lock held + held, self._held = self._held, {} + written = False + + for row_id, repeats in held.items(): + row = Notice.get_or_none(Notice.id == row_id) + + # resolved while its repeats waited + if row is None: + continue + + self._write_repeats( + row, + repeats.kind, + repeats.count, + repeats.last_seen, + repeats.params, + ) + written = True + + return written + def _write_repeats( self, row: Notice, diff --git a/frigate/notices/types.py b/frigate/notices/types.py index 411775d89d..c410c8f34f 100644 --- a/frigate/notices/types.py +++ b/frigate/notices/types.py @@ -99,6 +99,16 @@ _KINDS = ( link="https://github.com/blakeblackshear/frigate/releases/tag/v{version}", counts_repeats=False, keep_latest=1, + reportable=False, + ), + # raised while analytics is off; the row keeps a dismissal across restarts + NoticeKind( + "analytics_prompt", + NoticeSeverity.info, + "system", + link="/settings?page=systemTelemetry", + counts_repeats=False, + reportable=False, ), ) diff --git a/frigate/object_detection/base.py b/frigate/object_detection/base.py index de2e8eeaee..1390fddefb 100644 --- a/frigate/object_detection/base.py +++ b/frigate/object_detection/base.py @@ -23,7 +23,7 @@ from frigate.detectors.detector_config import ( InputDTypeEnum, ModelConfig, ) -from frigate.util.builtin import EventsPerSecond, load_labels +from frigate.util.builtin import EventsPerSecond, InferenceSpeed, load_labels from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory from frigate.util.process import FrigateProcess @@ -128,7 +128,7 @@ class DetectorRunner(FrigateProcess): super().__init__(stop_event, PROCESS_PRIORITY_HIGH, name=name, daemon=True) self.detection_queue = detection_queue self.cameras = cameras - self.avg_speed = avg_speed + self.avg_speed = InferenceSpeed(avg_speed) self.start_time = start_time self.config = config self.detector_config = detector_config @@ -182,7 +182,7 @@ class DetectorRunner(FrigateProcess): detector_publisher.publish(connection_id) self.start_time.value = 0.0 - self.avg_speed.value = (self.avg_speed.value * 9 + duration) / 10 + self.avg_speed.update(duration) detector_publisher.stop() logger.info("Exited detection process...") @@ -203,7 +203,7 @@ class AsyncDetectorRunner(FrigateProcess): super().__init__(stop_event, PROCESS_PRIORITY_HIGH, name=name, daemon=True) self.detection_queue = detection_queue self.cameras = cameras - self.avg_speed = avg_speed + self.avg_speed = InferenceSpeed(avg_speed) self.start_time = start_time self.config = config self.detector_config = detector_config @@ -272,7 +272,7 @@ class AsyncDetectorRunner(FrigateProcess): self._publisher.publish(connection_id) # update timers - self.avg_speed.value = (self.avg_speed.value * 9 + duration) / 10 + self.avg_speed.update(duration) self.start_time.value = 0.0 def run(self) -> None: @@ -328,7 +328,8 @@ class ObjectDetectProcess: self.name = name self.cameras = cameras self.detection_queue = detection_queue - self.avg_inference_speed = Value("d", 0.01) + # stays 0 until the first inference, so stats can tell an idle detector + self.avg_inference_speed = Value("d", 0.0) self.detection_start = Value("d", 0.0) self.detect_process: FrigateProcess | None = None self.config = config diff --git a/frigate/test/analytics_helpers.py b/frigate/test/analytics_helpers.py new file mode 100644 index 0000000000..6cb18d3ef0 --- /dev/null +++ b/frigate/test/analytics_helpers.py @@ -0,0 +1,36 @@ +"""Shared fixtures for the analytics tests.""" + +from typing import Any + +from frigate.analytics.context import ReportContext +from frigate.config import FrigateConfig + +FRONT_CAMERA: dict[str, Any] = { + "ffmpeg": {"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]}, + "detect": {"height": 720, "width": 1280, "fps": 5}, +} + + +def make_config(config: dict[str, Any] | None = None) -> FrigateConfig: + """A parsed config; top-level keys in config replace the defaults here. + + hwaccel_args is set so parsing never probes the test host's GPU. + """ + base: dict[str, Any] = { + "mqtt": {"host": "mqtt"}, + "ffmpeg": {"hwaccel_args": []}, + "cameras": {"front": FRONT_CAMERA}, + } + return FrigateConfig(**{**base, **(config or {})}) + + +def make_context( + config: FrigateConfig | None = None, + stats: dict[str, Any] | None = None, + notice_stats: list[dict[str, Any]] | None = None, +) -> ReportContext: + return ReportContext( + config=config or make_config(), + stats=stats or {}, + notice_stats=notice_stats or [], + ) diff --git a/frigate/test/http_api/test_http_analytics.py b/frigate/test/http_api/test_http_analytics.py new file mode 100644 index 0000000000..84e540b15a --- /dev/null +++ b/frigate/test/http_api/test_http_analytics.py @@ -0,0 +1,49 @@ +"""Tests for the analytics preview API.""" + +from unittest.mock import Mock, patch + +from frigate.analytics import report +from frigate.analytics.collectors import hardware +from frigate.models import Notice, NoticeStats, User +from frigate.notices.registry import NoticeRegistry +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestHttpAnalytics(BaseTestHttp): + def setUp(self): + super().setUp([Notice, NoticeStats, User]) + stats = Mock() + stats.get_latest_stats.return_value = self.test_stats + self.app = self.create_app(stats=stats, notice_registry=NoticeRegistry()) + + for target, name, value in ( + (report, "load_state", None), + (hardware.hardware_prober, "probe", []), + (hardware, "hwaccel_options", ("", [])), + ): + patcher = patch.object(target, name, return_value=value) + patcher.start() + self.addCleanup(patcher.stop) + + def test_preview_returns_the_report_without_sending_it(self): + with ( + patch("frigate.analytics.transport.requests.post") as post, + AuthTestClient(self.app) as client, + ): + response = client.get("/analytics/preview") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["install_id"], report.PREVIEW_INSTALL_ID) + self.assertEqual(body["schema_version"], 1) + self.assertEqual(body["cameras"]["total"], 1) + post.assert_not_called() + + def test_preview_is_admin_only(self): + with AuthTestClient(self.app) as client: + response = client.get( + "/analytics/preview", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + ) + + self.assertEqual(response.status_code, 403) diff --git a/frigate/test/test_analytics_collectors_cameras.py b/frigate/test/test_analytics_collectors_cameras.py new file mode 100644 index 0000000000..300733adb0 --- /dev/null +++ b/frigate/test/test_analytics_collectors_cameras.py @@ -0,0 +1,133 @@ +"""Tests for the cameras collector.""" + +import unittest + +from frigate.analytics.collectors import cameras +from frigate.analytics.schema import HeightBucket, HwaccelKey, RetainBucket +from frigate.test.analytics_helpers import make_config, make_context + +MASK = {"coordinates": "0,0,1,0,1,1"} + +CONFIG = { + "audio_transcription": {"enabled": True}, + "record": { + "enabled": True, + "continuous": {"days": 3}, + "alerts": {"retain": {"days": 14}}, + "detections": {"retain": {"days": 45}}, + }, + "cameras": { + "front": { + "ffmpeg": { + "hwaccel_args": "preset-vaapi", + "inputs": [ + { + "path": "rtsp://127.0.0.1:8554/front_sub", + "roles": ["detect"], + "input_args": "preset-rtsp-restream", + }, + {"path": "rtsp://127.0.0.1:8554/front", "roles": ["record"]}, + ], + }, + "detect": {"enabled": True, "height": 720, "width": 1280, "fps": 5}, + "zones": {"porch": MASK, "yard": MASK}, + "motion": {"mask": {"tree": MASK}}, + "objects": { + "mask": {"sign": MASK}, + "filters": {"person": {"mask": {"bush": MASK}}}, + }, + "onvif": {"host": "10.0.0.9"}, + }, + "garage": { + "type": "lpr", + "ffmpeg": { + "hwaccel_args": ["-hwaccel", "vaapi"], + "inputs": [ + { + "path": "rtsp://10.0.0.5/stream", + "roles": ["detect", "record", "audio"], + } + ], + }, + "audio": {"enabled": True}, + "detect": {"height": 2160, "width": 3840, "fps": 15}, + "record": {"enabled": False, "sub": {"enabled": True}}, + }, + "attic": { + "enabled": False, + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.6/stream", "roles": ["detect"]}] + }, + "detect": {"enabled": True, "height": 480, "width": 640, "fps": 5}, + }, + "_replay_front": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.1/replay", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + }, +} + +STATS = { + "cameras": { + "front": {"connection_quality": "excellent"}, + "garage": {"connection_quality": "offline"}, + "attic": {"connection_quality": "unusable"}, + "_replay_front": {"connection_quality": "fair"}, + } +} + + +class TestCamerasCollector(unittest.TestCase): + def test_aggregates_enabled_cameras_and_skips_replays(self): + section = cameras.collect(make_context(make_config(CONFIG), STATS)) + + self.assertEqual((section.total, section.enabled), (3, 2)) + self.assertEqual(section.types, {"generic": 1, "lpr": 1}) + self.assertEqual(section.detect_height, {"720": 1, "ge_2160": 1}) + self.assertEqual(section.detect_fps, {"le_5": 1, "gt_10": 1}) + self.assertEqual(section.hwaccel, {"vaapi": 1, "custom": 1}) + self.assertEqual(section.input_preset, {"rtsp-restream": 1, "rtsp-generic": 1}) + self.assertEqual(section.go2rtc_restream, 1) + self.assertEqual(section.separate_detect_stream, 1) + self.assertEqual((section.detect, section.record, section.onvif), (1, 1, 1)) + self.assertEqual((section.zones, section.cameras_with_zones), (2, 1)) + self.assertEqual((section.motion_masks, section.object_masks), (1, 2)) + self.assertEqual((section.audio, section.audio_transcription), (1, 1)) + self.assertEqual(section.sub_stream_record, 0) + self.assertEqual(section.connection_quality, {"excellent": 1}) + + def test_runtime_disabled_camera_skips_connection_quality(self): + config = make_config(CONFIG) + config.cameras["front"].enabled = False + section = cameras.collect(make_context(config, STATS)) + + self.assertEqual(section.connection_quality, {}) + self.assertEqual(section.retain_days.continuous, {"1_7": 1}) + self.assertEqual(section.retain_days.motion, {"0": 1}) + self.assertEqual(section.retain_days.alerts, {"8_30": 1}) + self.assertEqual(section.retain_days.detections, {"gt_30": 1}) + + def test_buckets(self): + self.assertEqual(cameras.height_bucket(360), HeightBucket.le_360) + self.assertEqual(cameras.height_bucket(361), HeightBucket.h480) + self.assertEqual(cameras.height_bucket(1080), HeightBucket.h1080) + self.assertEqual(cameras.height_bucket(1440), HeightBucket.h1440) + self.assertEqual(cameras.height_bucket(2160), HeightBucket.ge_2160) + self.assertEqual(cameras.retain_bucket(0.5), RetainBucket.d1_7) + self.assertEqual(cameras.retain_bucket(0), RetainBucket.zero) + + def test_preset_key(self): + self.assertEqual(cameras.preset_key([], HwaccelKey), "none") + self.assertEqual(cameras.preset_key("", HwaccelKey), "none") + self.assertEqual(cameras.preset_key("preset-bogus", HwaccelKey), "custom") + self.assertEqual(cameras.preset_key("-hwaccel vaapi", HwaccelKey), "custom") + self.assertEqual(cameras.preset_key("preset-nvidia", HwaccelKey), "nvidia") + + def test_is_restream(self): + self.assertTrue(cameras.is_restream("rtsp://127.0.0.1:8554/front")) + self.assertTrue(cameras.is_restream("rtsp://localhost:8554/front")) + self.assertFalse(cameras.is_restream("rtsp://10.0.0.5:8554/front")) + self.assertFalse(cameras.is_restream("rtsp://[bad")) + self.assertFalse(cameras.is_restream("rtsp://127.0.0.1:notaport/x")) diff --git a/frigate/test/test_analytics_collectors_detection.py b/frigate/test/test_analytics_collectors_detection.py new file mode 100644 index 0000000000..7061eb8d09 --- /dev/null +++ b/frigate/test/test_analytics_collectors_detection.py @@ -0,0 +1,87 @@ +"""Tests for the detection collector.""" + +import os +import tempfile +import unittest +from unittest.mock import patch + +from frigate.analytics.collectors import detection +from frigate.analytics.schema import ModelSource +from frigate.test.analytics_helpers import make_config, make_context + + +class TestDetectionCollector(unittest.TestCase): + def test_reports_each_model_with_its_mean_inference_speed(self): + config = make_config({"models": [{"devices": ["cpu", "cpu"]}]}) + stats = { + "detectors": { + "cpu": {"inference_speed": 10.0}, + "cpu#2": {"inference_speed": 20.0}, + }, + "detection_fps": 12.346, + "skipped_fps": 0, + } + + section = detection.collect(make_context(config, stats)) + + [model] = section.models + self.assertTrue(model.default) + self.assertEqual(model.detector, "cpu") + self.assertEqual(model.devices, 2) + self.assertEqual(model.model_type, "ssd") + self.assertEqual(model.input, "320x320") + self.assertEqual(model.source, ModelSource.default) + self.assertEqual(model.inference_ms, 15.0) + self.assertEqual(section.detection_fps, 12.35) + self.assertEqual(section.skipped_fps, 0.0) + + def test_idle_detector_reports_no_speed(self): + config = make_config({"models": [{"devices": ["cpu"]}]}) + stats = {"detectors": {"cpu": {"inference_speed": 0.0}}} + + [model] = detection.collect(make_context(config, stats)).models + self.assertIsNone(model.inference_ms) + + def test_named_scene_reports_no_name(self): + config = make_config( + { + "models": [ + {"devices": ["cpu"]}, + { + "scene": "backyard_thermal", + "devices": ["cpu"], + "path": "/thermal.tflite", + }, + ] + } + ) + + section = detection.collect(make_context(config)) + + self.assertEqual([m.default for m in section.models], [True, False]) + self.assertNotIn("backyard_thermal", section.model_dump_json()) + + def test_inference_is_null_before_the_first_stats(self): + section = detection.collect(make_context()) + + self.assertIsNone(section.models[0].inference_ms) + + def test_model_source(self): + with tempfile.TemporaryDirectory() as cache: + plus_model = os.path.join(cache, "abc123") + open(f"{plus_model}.json", "w").close() + + with patch.object(detection, "MODEL_CACHE_DIR", cache): + self.assertEqual(detection.model_source(plus_model), ModelSource.plus) + self.assertEqual( + detection.model_source(os.path.join(cache, "no_info")), + ModelSource.custom, + ) + + self.assertEqual(detection.model_source(None), ModelSource.default) + self.assertEqual( + detection.model_source("/cpu_model.tflite"), ModelSource.default + ) + self.assertEqual( + detection.model_source("/config/yolo.onnx"), ModelSource.custom + ) diff --git a/frigate/test/test_analytics_collectors_features.py b/frigate/test/test_analytics_collectors_features.py new file mode 100644 index 0000000000..147f70a0ab --- /dev/null +++ b/frigate/test/test_analytics_collectors_features.py @@ -0,0 +1,129 @@ +"""Tests for the features collector.""" + +import logging +import os +import unittest + +from peewee import SqliteDatabase +from peewee_migrate import Router +from playhouse.sqliteq import SqliteQueueDatabase + +from frigate.analytics.collectors import features +from frigate.analytics.schema import EnrichmentDevice, SemanticSearchModel +from frigate.models import User +from frigate.test.analytics_helpers import FRONT_CAMERA, make_config, make_context +from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS + +CONFIG = { + "mqtt": {"host": "mqtt", "enabled": True}, + "genai": { + "local": { + "provider": "ollama", + "model": "llava", + "base_url": "http://ollama:11434", + "roles": ["descriptions", "embeddings"], + } + }, + "semantic_search": {"enabled": True, "model": "local"}, + "face_recognition": {"enabled": True, "model_size": "large"}, + "birdseye": {"enabled": True, "modes": ["motion", "alerts"], "restream": True}, + "proxy": {"header_map": {"user": "x-forwarded-user"}}, + "camera_groups": {"outside": {"cameras": ["front"], "icon": "LuCar", "order": 0}}, + "cameras": { + "front": { + **FRONT_CAMERA, + "semantic_search": { + "triggers": { + "cat": { + "type": "description", + "data": "a cat", + "threshold": 0.8, + "actions": ["notification"], + } + } + }, + } + }, +} + + +class TestFeaturesCollector(unittest.TestCase): + def setUp(self): + migrate_db = SqliteDatabase(TEST_DB) + del logging.getLogger("peewee_migrate").handlers[:] + Router(migrate_db).run() + migrate_db.close() + self.db = SqliteQueueDatabase(TEST_DB) + self.db.bind([User]) + + def tearDown(self): + # close() leaves the queue's writer thread running against the deleted file + self.db.stop() + + if not self.db.is_closed(): + self.db.close() + + for file in TEST_DB_CLEANUPS: + try: + os.remove(file) + except OSError: + pass + + def test_collects_enrichments_genai_integrations_and_users(self): + for username, role in (("a", "admin"), ("v", "viewer"), ("o", "operator")): + User.create( + username=username, role=role, password_hash="x", notification_tokens=[] + ) + + stats = { + "embeddings": { + "devices": { + "face_recognition": "OpenVINO GPU.0", + "semantic_search": "CUDA", + } + } + } + + section = features.collect(make_context(make_config(CONFIG), stats)) + + self.assertEqual(section.face_recognition.device, EnrichmentDevice.openvino_gpu) + self.assertEqual(section.face_recognition.model_size, "large") + self.assertIsNone(section.lpr.device) + self.assertEqual(section.semantic_search.model, SemanticSearchModel.genai) + self.assertEqual(section.semantic_search.device, EnrichmentDevice.cuda) + self.assertEqual(section.semantic_search.triggers, 1) + self.assertEqual(section.genai.providers, {"ollama": 1}) + self.assertEqual(section.genai.roles, {"descriptions": 1, "embeddings": 1}) + self.assertEqual(section.birdseye.modes, ["motion", "alerts"]) + self.assertTrue(section.mqtt) + self.assertTrue(section.proxy_auth) + self.assertEqual(section.users, {"admin": 1, "viewer": 1, "custom": 1}) + self.assertEqual((section.camera_groups, section.profiles), (1, 0)) + self.assertFalse(section.plus_api_key) + + +class TestFeatureMappings(unittest.TestCase): + def test_enrichment_device_labels(self): + cases = { + "CPU": EnrichmentDevice.cpu, + "MIGraphX": EnrichmentDevice.migraphx, + "OpenVINO NPU": EnrichmentDevice.openvino_npu, + "OpenVINO GPU.0,CPU": EnrichmentDevice.openvino_gpu, + "OpenVINO (CPU)": EnrichmentDevice.openvino_cpu, + "OpenVINO": EnrichmentDevice.other, + "CoreML": EnrichmentDevice.other, + } + + for label, device in cases.items(): + with self.subTest(label=label): + self.assertEqual(features.enrichment_device(label), device) + + self.assertIsNone(features.enrichment_device(None)) + + def test_models_named_after_a_provider_are_genai(self): + self.assertEqual(features.semantic_model("jinav2"), SemanticSearchModel.jinav2) + self.assertEqual( + features.semantic_model("my-ollama"), SemanticSearchModel.genai + ) + self.assertIsNone(features.semantic_model(None)) + self.assertEqual(features.transcription_model("openai"), "genai") diff --git a/frigate/test/test_analytics_collectors_health.py b/frigate/test/test_analytics_collectors_health.py new file mode 100644 index 0000000000..b965594811 --- /dev/null +++ b/frigate/test/test_analytics_collectors_health.py @@ -0,0 +1,71 @@ +"""Tests for the health collector.""" + +import unittest + +from frigate.analytics.collectors import health +from frigate.test.analytics_helpers import make_context + + +class TestHealthCollector(unittest.TestCase): + def test_collects_uptime_cpu_timings_and_notices_since_the_baseline(self): + stats = { + "service": {"uptime": 7300, "retention_unmet": True}, + "cpu_usages": {"frigate.full_system": {"cpu": "23.6", "mem": "40"}}, + "embeddings": { + "face_recognition_speed": 41.234, + "text_embedding_speed": 0, + "devices": {}, + }, + } + notice_stats = [ + { + "kind": "detector_stuck", + "occurrences": 5, + "acknowledgements": 1, + "mutes": 0, + "reported_occurrences": 3, + "reported_acknowledgements": 1, + "reported_mutes": 0, + }, + { + "kind": "shm_too_low", + "occurrences": 2, + "acknowledgements": 0, + "mutes": 0, + "reported_occurrences": 2, + "reported_acknowledgements": 0, + "reported_mutes": 0, + }, + { + "kind": "update_available", + "occurrences": 4, + "acknowledgements": 0, + "mutes": 0, + "reported_occurrences": 0, + "reported_acknowledgements": 0, + "reported_mutes": 0, + }, + ] + + section = health.collect(make_context(stats=stats, notice_stats=notice_stats)) + + self.assertEqual(section.uptime_hours, 2) + self.assertEqual(section.cpu_percent, 24) + self.assertEqual(section.enrichment_ms, {"face": 41.23}) + self.assertTrue(section.retention_unmet) + self.assertEqual( + { + kind: (c.occurrences, c.acknowledgements, c.mutes) + for kind, c in section.notices.items() + }, + {"detector_stuck": (2, 0, 0)}, + ) + + def test_missing_stats_give_zeros(self): + section = health.collect(make_context()) + + self.assertEqual( + (section.uptime_hours, section.cpu_percent, section.enrichment_ms), + (0, 0, {}), + ) + self.assertEqual(section.notices, {}) diff --git a/frigate/test/test_analytics_collectors_system.py b/frigate/test/test_analytics_collectors_system.py new file mode 100644 index 0000000000..c87b402e52 --- /dev/null +++ b/frigate/test/test_analytics_collectors_system.py @@ -0,0 +1,188 @@ +"""Tests for the install and hardware collectors.""" + +import os +import tempfile +import unittest +from collections import Counter +from types import SimpleNamespace +from unittest.mock import patch + +from frigate.analytics.collectors import hardware, install +from frigate.analytics.collectors.common import closed, histogram, rate +from frigate.analytics.schema import ( + Arch, + DecodeFamily, + GpuVendor, + HardwareKey, + ImageVariant, + InstallType, +) +from frigate.const import RECORD_DIR +from frigate.test.analytics_helpers import make_context + + +class TestCommon(unittest.TestCase): + def test_closed_falls_back_for_unknown_values(self): + self.assertEqual(closed(Arch, "x86_64", Arch.other), Arch.x86_64) + self.assertEqual(closed(Arch, "sparc", Arch.other), Arch.other) + self.assertEqual(closed(Arch, None, Arch.other), Arch.other) + + def test_rate_rounds_and_rejects_bad_numbers(self): + self.assertEqual(rate("12.346"), 12.35) + self.assertEqual(rate(-3), 0.0) + self.assertEqual(rate(float("nan")), 0.0) + self.assertEqual(rate(float("inf")), 0.0) + self.assertEqual(rate(None), 0.0) + + def test_histogram_drops_empty_buckets(self): + self.assertEqual( + histogram(Counter({Arch.x86_64: 2, Arch.other: 0})), {"x86_64": 2} + ) + + +class TestInstallCollector(unittest.TestCase): + def test_collects_version_variant_and_platform(self): + with ( + patch.dict(os.environ, {"FRIGATE_IMAGE_VARIANT": "tensorrt-jp6"}), + patch.object(install, "install_type", return_value=InstallType.docker), + patch("platform.machine", return_value="aarch64"), + patch("platform.release", return_value="6.8.0-45-generic"), + patch("os.geteuid", return_value=1000), + ): + section = install.collect(make_context()) + + self.assertEqual(section.image_variant, ImageVariant.tensorrt_jp6) + self.assertEqual(section.install_type, InstallType.docker) + self.assertEqual(section.arch, Arch.aarch64) + self.assertEqual(section.kernel, "6.8") + self.assertFalse(section.run_as_root) + + def test_image_variant_is_dev_when_unset_and_other_when_unknown(self): + self.assertEqual(install.image_variant(None), ImageVariant.dev) + self.assertEqual(install.image_variant(""), ImageVariant.dev) + self.assertEqual(install.image_variant("h8l"), ImageVariant.other) + + def test_install_type_checks_the_add_on_first(self): + with patch("os.path.isfile", return_value=True): + self.assertEqual(install.install_type(), InstallType.ha_addon) + + with ( + patch("os.path.isfile", return_value=False), + patch.dict(os.environ, {"KUBERNETES_SERVICE_HOST": "10.0.0.1"}), + ): + self.assertEqual(install.install_type(), InstallType.kubernetes) + + env = {k: v for k, v in os.environ.items() if k != "KUBERNETES_SERVICE_HOST"} + + with ( + patch("os.path.isfile", return_value=False), + patch.dict(os.environ, env, clear=True), + patch("os.path.exists", side_effect=lambda path: path == "/.dockerenv"), + ): + self.assertEqual(install.install_type(), InstallType.docker) + + def test_kernel_and_arch_fall_back(self): + self.assertEqual(install.kernel("weird"), "unknown") + self.assertEqual(install.arch("armv7l"), Arch.other) + self.assertEqual(install.arch("amd64"), Arch.x86_64) + + +class TestHardwareCollector(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.addCleanup(self.dir.cleanup) + + def write(self, name: str, content: str) -> str: + path = os.path.join(self.dir.name, name) + + with open(path, "w") as f: + f.write(content) + + return path + + def test_cpu_model_prefers_the_board_model(self): + board = self.write("model", "Raspberry Pi 5 Model B Rev 1.0\x00") + cpuinfo = self.write("cpuinfo", "model name\t: Cortex-A76\n") + + with ( + patch.object(hardware, "DEVICE_TREE_MODEL", board), + patch.object(hardware, "CPUINFO", cpuinfo), + ): + self.assertEqual(hardware.cpu_model(), "Raspberry Pi 5 Model B Rev 1.0") + + def test_cpu_model_reads_cpuinfo_and_truncates(self): + cpuinfo = self.write("cpuinfo", "processor\t: 0\nmodel name\t: " + "x" * 80) + + with ( + patch.object(hardware, "DEVICE_TREE_MODEL", self.dir.name + "/none"), + patch.object(hardware, "CPUINFO", cpuinfo), + ): + self.assertEqual(hardware.cpu_model(), "x" * 64) + + def test_collects_gpus_decode_detection_and_storage(self): + stats = { + "gpu_usages": { + "NVIDIA GeForce RTX 3060": {"vendor": "nvidia"}, + "mystery": {}, + }, + "service": { + "storage": { + RECORD_DIR: { + "total": 2048000.0, + "used": 512000.0, + "mount_type": "nfs4", + } + } + }, + } + probed = [ + SimpleNamespace(key="edgetpu:usb", count=2), + SimpleNamespace(key="new:thing", count=1), + SimpleNamespace(key="cpu", count=1), + ] + families = [ + SimpleNamespace(key="vaapi"), + SimpleNamespace(key="intel-qsv"), + SimpleNamespace(key="future"), + ] + + with ( + patch.object(hardware.hardware_prober, "probe", return_value=probed), + patch.object(hardware, "hwaccel_options", return_value=("vaapi", families)), + patch.object(hardware, "cpu_model", return_value="Intel(R) N100"), + patch("os.cpu_count", return_value=4), + patch( + "psutil.virtual_memory", return_value=SimpleNamespace(total=16 * 2**30) + ), + ): + section = hardware.collect(make_context(stats=stats)) + + self.assertEqual(section.cpu_model, "Intel(R) N100") + self.assertEqual(section.cpu_cores, 4) + self.assertEqual(section.memory_gb, 16) + self.assertEqual( + [(gpu.vendor, gpu.name) for gpu in section.gpus], + [ + (GpuVendor.nvidia, "NVIDIA GeForce RTX 3060"), + (GpuVendor.other, "mystery"), + ], + ) + self.assertEqual( + section.decode_families, + [DecodeFamily.vaapi, DecodeFamily.intel_qsv, DecodeFamily.other], + ) + self.assertEqual( + section.detection_hardware, + {HardwareKey.edgetpu_usb: 2, HardwareKey.other: 1, HardwareKey.cpu: 1}, + ) + self.assertEqual(section.storage.record_fs, "nfs4") + self.assertEqual(section.storage.record_total_gb, 2000) + self.assertEqual(section.storage.record_used_pct, 25) + + def test_storage_without_stats_is_unknown_and_empty(self): + section = hardware.storage({}) + + self.assertEqual( + (section.record_fs, section.record_total_gb, section.record_used_pct), + ("unknown", 0, 0), + ) diff --git a/frigate/test/test_analytics_report.py b/frigate/test/test_analytics_report.py new file mode 100644 index 0000000000..33a359dbef --- /dev/null +++ b/frigate/test/test_analytics_report.py @@ -0,0 +1,143 @@ +"""Tests for building a report from the collectors.""" + +import json +import logging +import os +import unittest +from unittest.mock import Mock, patch + +from peewee import SqliteDatabase +from peewee_migrate import Router +from playhouse.sqliteq import SqliteQueueDatabase + +from frigate.analytics import report +from frigate.analytics.collectors import hardware +from frigate.analytics.schema import AnalyticsReport, InstallSection +from frigate.analytics.state import AnalyticsState +from frigate.models import User +from frigate.test.analytics_helpers import make_context +from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS + +INSTALL = InstallSection( + version="0.19.0", + image_variant="dev", + install_type="docker", + arch="x86_64", + kernel="6.8", + run_as_root=False, +) + + +class TestBuildReport(unittest.TestCase): + def test_a_failing_collector_nulls_only_its_section(self): + collectors = { + "install": lambda ctx: INSTALL, + "hardware": Mock(side_effect=RuntimeError("boom")), + "detection": lambda ctx: None, + "cameras": lambda ctx: None, + "features": lambda ctx: None, + "health": lambda ctx: None, + } + + with patch.dict(report.COLLECTORS, collectors): + built = report.build_report(make_context(), "a" * 32, sent_at=1790000000) + + self.assertEqual(built.install, INSTALL) + self.assertIsNone(built.hardware) + self.assertEqual(built.schema_version, 1) + self.assertEqual(built.sent_at, 1790000000) + self.assertRegex(built.report_id, r"^[0-9a-f-]{36}$") + + +class TestRealCollectors(unittest.TestCase): + def setUp(self): + migrate_db = SqliteDatabase(TEST_DB) + del logging.getLogger("peewee_migrate").handlers[:] + Router(migrate_db).run() + migrate_db.close() + self.db = SqliteQueueDatabase(TEST_DB) + self.db.bind([User]) + + def tearDown(self): + # close() leaves the queue's writer thread running against the deleted file + self.db.stop() + + if not self.db.is_closed(): + self.db.close() + + for file in TEST_DB_CLEANUPS: + try: + os.remove(file) + except OSError: + pass + + def test_every_section_builds_and_round_trips_through_json(self): + with ( + patch.object(hardware.hardware_prober, "probe", return_value=[]), + patch.object(hardware, "hwaccel_options", return_value=("", [])), + ): + built = report.build_report(make_context(), "a" * 32) + + body = built.model_dump_json() + parsed = json.loads(body) + + for section in ( + "install", + "hardware", + "detection", + "cameras", + "features", + "health", + ): + with self.subTest(section=section): + self.assertIsNotNone(parsed[section]) + + self.assertEqual(AnalyticsReport.model_validate_json(body), built) + + def test_preview_uses_a_placeholder_id_before_the_first_report(self): + stats_emitter = Mock() + stats_emitter.get_latest_stats.return_value = {} + + with ( + patch.object(report, "load_state", return_value=None), + patch.object(hardware.hardware_prober, "probe", return_value=[]), + patch.object(hardware, "hwaccel_options", return_value=("", [])), + ): + built = report.preview_report(make_context().config, stats_emitter, None) + + self.assertEqual(built.install_id, report.PREVIEW_INSTALL_ID) + + def test_preview_counts_notices_only_once_opted_in(self): + stats_emitter = Mock() + stats_emitter.get_latest_stats.return_value = {} + registry = Mock() + registry.stats.return_value = [ + { + "kind": "detector_stuck", + "occurrences": 3, + "acknowledgements": 0, + "mutes": 0, + "reported_occurrences": 1, + "reported_acknowledgements": 0, + "reported_mutes": 0, + } + ] + + for state, expected in ( + (None, {}), + (AnalyticsState("a" * 32, 0.0), {"detector_stuck": 2}), + ): + with ( + self.subTest(opted_in=state is not None), + patch.object(report, "load_state", return_value=state), + patch.object(hardware.hardware_prober, "probe", return_value=[]), + patch.object(hardware, "hwaccel_options", return_value=("", [])), + ): + built = report.preview_report( + make_context().config, stats_emitter, registry + ) + + self.assertEqual( + {kind: c.occurrences for kind, c in built.health.notices.items()}, + expected, + ) diff --git a/frigate/test/test_analytics_reporter.py b/frigate/test/test_analytics_reporter.py new file mode 100644 index 0000000000..2bae0660ce --- /dev/null +++ b/frigate/test/test_analytics_reporter.py @@ -0,0 +1,349 @@ +"""Tests for the analytics reporter's schedule and consent handling.""" + +import os +import tempfile +import threading +import unittest +from unittest.mock import Mock, patch + +from frigate.analytics import reporter as reporter_module +from frigate.analytics.reporter import ( + FIRST_DELAY_S, + INTERVAL_S, + JITTER_S, + PROMPT_KIND, + WAKE_S, + AnalyticsReporter, +) +from frigate.analytics.state import AnalyticsState, load_state, save_state +from frigate.analytics.transport import SendOutcome +from frigate.config.holder import ConfigHolder +from frigate.test.analytics_helpers import make_config + + +class TestAnalyticsReporter(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.addCleanup(self.dir.cleanup) + self.path = os.path.join(self.dir.name, ".analytics.json") + self.config_path = os.path.join(self.dir.name, "config.yml") + self.save_config("telemetry:\n analytics: true\n") + self.now = 1_790_000_000.0 + self.send = Mock(return_value=SendOutcome.accepted) + self.registry = Mock() + self.stats = Mock() + self.stats.get_latest_stats.return_value = {} + self.holder = ConfigHolder(make_config({"telemetry": {"analytics": True}})) + + for name in ("raise_notice", "resolve_notice"): + patcher = patch.object(reporter_module, name) + setattr(self, name, patcher.start()) + self.addCleanup(patcher.stop) + + self.built = Mock() + self.built.model_dump_json.return_value = '{"report": 1}' + patcher = patch.object(reporter_module, "build_report", return_value=self.built) + self.build_report = patcher.start() + self.addCleanup(patcher.stop) + + def save_config(self, text: str) -> None: + with open(self.config_path, "w") as f: + f.write(text) + + def seed_state(self, last_attempt_at: float = 0.0) -> None: + save_state(AnalyticsState("a" * 32, last_attempt_at), self.path) + + def reporter(self, path: str | None = None) -> AnalyticsReporter: + # uniform(a, b) returns a: the shortest startup delay and interval + rng = Mock() + rng.uniform.side_effect = lambda low, high: low + + return AnalyticsReporter( + self.holder, + self.stats, + self.registry, + threading.Event(), + state_path=path or self.path, + config_path=self.config_path, + url="https://example.test/report", + send=self.send, + clock=lambda: self.now, + rng=rng, + ) + + def advance(self, seconds: float) -> None: + self.now += seconds + + def test_waits_for_the_startup_delay_then_sends(self): + self.seed_state() + reporter = self.reporter() + + reporter.tick() + self.send.assert_not_called() + + self.advance(FIRST_DELAY_S[0]) + reporter.tick() + + self.send.assert_called_once_with( + "https://example.test/report", '{"report": 1}' + ) + self.assertEqual(load_state(self.path).last_attempt_at, self.now) + + def test_sends_once_per_interval(self): + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + reporter.tick() + + self.advance(WAKE_S) + reporter.tick() + self.assertEqual(self.send.call_count, 1) + + self.advance(INTERVAL_S - JITTER_S) + reporter.tick() + self.assertEqual(self.send.call_count, 2) + + def test_a_restart_inside_the_interval_does_not_send(self): + self.seed_state(self.now) + reporter = self.reporter() + + self.advance(FIRST_DELAY_S[0]) + reporter.tick() + + self.send.assert_not_called() + + def test_a_failed_send_waits_a_full_interval(self): + self.send.return_value = SendOutcome.failed + self.seed_state() + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + + reporter.tick() + self.advance(WAKE_S) + reporter.tick() + + self.assertEqual(self.send.call_count, 1) + + def assert_reset_before_the_state_file(self) -> None: + # the baseline must be in place before the file names the new ID + self.registry.reset_analytics_baseline.side_effect = lambda: self.assertIsNone( + load_state(self.path) + ) + + def test_the_opt_in_resets_the_baseline_and_writes_the_identity(self): + self.assert_reset_before_the_state_file() + reporter = self.reporter() + + # consent is seen on the first wake, well before the first send + reporter.tick() + self.registry.reset_analytics_baseline.assert_called_once() + self.assertIsNotNone(load_state(self.path)) + self.send.assert_not_called() + + # the first report counts from that reset, so nothing resets again + self.advance(FIRST_DELAY_S[0]) + reporter.tick() + + self.send.assert_called_once() + self.registry.reset_analytics_baseline.assert_called_once() + + def test_a_restart_with_saved_state_keeps_the_baseline(self): + self.seed_state() + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + + reporter.tick() + + self.send.assert_called_once() + self.registry.reset_analytics_baseline.assert_not_called() + + def test_a_settings_opt_in_resets_the_baseline_when_saved(self): + self.holder.set(make_config()) + reporter = self.reporter() + reporter.tick() + + self.holder.set(make_config({"telemetry": {"analytics": True}})) + + self.registry.reset_analytics_baseline.assert_called_once() + self.assertIsNotNone(load_state(self.path)) + + def test_a_re_opt_in_during_the_request_keeps_its_new_identity(self): + self.seed_state() + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + created = [] + + def flip_consent(*args): + self.holder.set(make_config()) + self.holder.set(make_config({"telemetry": {"analytics": True}})) + created.append(load_state(self.path)) + return SendOutcome.accepted + + self.send.side_effect = flip_consent + reporter.tick() + + # nothing after the send touches the new identity or its baseline + self.assertNotEqual(created[0].install_id, "a" * 32) + self.assertEqual(load_state(self.path), created[0]) + self.registry.reset_analytics_baseline.assert_called_once() + + def test_an_opt_in_without_a_writable_state_file_resets_again_later(self): + state_dir = os.path.join(self.dir.name, "missing") + reporter = self.reporter(os.path.join(state_dir, ".analytics.json")) + + with self.assertLogs("frigate.analytics.reporter", "WARNING"): + reporter.tick() + self.advance(FIRST_DELAY_S[0]) + reporter.tick() + + self.send.assert_not_called() + os.mkdir(state_dir) + self.registry.reset_analytics_baseline.reset_mock() + self.advance(WAKE_S) + reporter.tick() + + self.send.assert_called_once() + self.registry.reset_analytics_baseline.assert_called_once() + + def test_an_opt_out_saved_only_to_the_file_stops_the_send(self): + self.seed_state() + self.save_config("telemetry:\n analytics: false\n") + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + + reporter.tick() + + self.send.assert_not_called() + self.assertFalse(os.path.exists(self.path)) + self.raise_notice.assert_not_called() + + def test_reversing_a_file_only_opt_out_resets_the_baseline(self): + self.seed_state() + self.save_config("telemetry:\n analytics: false\n") + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + reporter.tick() + + self.save_config("telemetry:\n analytics: true\n") + self.assert_reset_before_the_state_file() + self.advance(WAKE_S) + reporter.tick() + + self.assertNotEqual(load_state(self.path).install_id, "a" * 32) + self.registry.reset_analytics_baseline.assert_called_once() + self.send.assert_called_once() + + def test_yaml_truthy_spellings_in_the_file_count_as_consent(self): + for value in ("yes", "on", "1", "'true'"): + with self.subTest(value=value): + self.seed_state() + self.send.reset_mock() + self.save_config(f"telemetry:\n analytics: {value}\n") + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + + reporter.tick() + + self.send.assert_called_once() + self.assertTrue(os.path.exists(self.path)) + + def test_an_unreadable_config_file_falls_back_to_the_live_config(self): + self.seed_state() + self.save_config("telemetry: [unclosed\n") + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + + reporter.tick() + + self.send.assert_called_once() + + def test_consent_withdrawn_while_the_report_builds_stops_the_send(self): + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + + def withdraw(*args, **kwargs): + self.holder.set(make_config()) + return self.built + + self.build_report.side_effect = withdraw + reporter.tick() + + self.send.assert_not_called() + self.assertFalse(os.path.exists(self.path)) + + def test_turning_sharing_off_and_on_between_wakes_starts_a_fresh_identity(self): + self.seed_state() + reporter = self.reporter() + reporter.tick() + + self.holder.set(make_config()) + self.holder.set(make_config({"telemetry": {"analytics": True}})) + self.advance(FIRST_DELAY_S[0]) + reporter.tick() + + self.raise_notice.assert_called_once_with(PROMPT_KIND) + self.assertNotEqual(load_state(self.path).install_id, "a" * 32) + + def test_opting_out_raises_the_prompt_once_and_deletes_the_state(self): + self.holder.set(make_config()) + self.seed_state() + reporter = self.reporter() + + reporter.tick() + reporter.tick() + + self.raise_notice.assert_called_once_with(PROMPT_KIND) + self.assertFalse(os.path.exists(self.path)) + self.send.assert_not_called() + + def test_opting_in_resolves_the_prompt(self): + self.holder.set(make_config()) + reporter = self.reporter() + reporter.tick() + + self.holder.set(make_config({"telemetry": {"analytics": True}})) + reporter.tick() + + self.resolve_notice.assert_called_once_with(PROMPT_KIND) + + def test_safe_mode_touches_nothing(self): + self.holder.set(make_config({"safe_mode": True})) + self.seed_state() + reporter = self.reporter() + + self.advance(FIRST_DELAY_S[0]) + reporter.tick() + + self.raise_notice.assert_not_called() + self.assertTrue(os.path.exists(self.path)) + self.send.assert_not_called() + + def test_an_unwritable_config_dir_skips_sending_and_warns_once(self): + reporter = self.reporter(os.path.join(self.dir.name, "missing", "x.json")) + self.advance(FIRST_DELAY_S[0]) + + with self.assertLogs("frigate.analytics.reporter", "WARNING") as logs: + reporter.tick() + self.advance(WAKE_S) + reporter.tick() + + self.send.assert_not_called() + self.assertEqual(len(logs.output), 1) + + def test_a_last_attempt_in_the_future_is_ignored(self): + self.seed_state(self.now + 10 * INTERVAL_S) + reporter = self.reporter() + + self.advance(FIRST_DELAY_S[0]) + reporter.tick() + + self.send.assert_called_once() + + def test_the_loop_survives_a_failing_tick(self): + reporter = self.reporter() + reporter.stop_event.set() + + with ( + patch.object(reporter, "tick", side_effect=RuntimeError("boom")), + self.assertLogs("frigate.analytics.reporter", "ERROR"), + ): + reporter.run() diff --git a/frigate/test/test_analytics_schema.py b/frigate/test/test_analytics_schema.py new file mode 100644 index 0000000000..1b7d7f03f8 --- /dev/null +++ b/frigate/test/test_analytics_schema.py @@ -0,0 +1,137 @@ +"""Tests for the analytics report schema.""" + +import inspect +import re +import unittest +from enum import Enum +from typing import Any, get_args, get_origin + +from pydantic import BaseModel + +import frigate.detectors.hardware as detection_hardware +import frigate.util.hwaccel as hwaccel +from frigate.analytics.schema import ( + AnalyticsReport, + DecodeFamily, + HardwareKey, + HwaccelKey, + InputPresetKey, + NoticeKindKey, +) +from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT +from frigate.notices.types import NOTICE_KINDS +from frigate.util.hwaccel import HwaccelFamily + +# the only str fields: generated ids and strings the hardware reports +ALLOWED_STRINGS = { + ("AnalyticsReport", "install_id"), + ("AnalyticsReport", "report_id"), + ("InstallSection", "version"), + ("InstallSection", "kernel"), + ("HardwareSection", "cpu_model"), + ("GpuInfo", "name"), + ("StorageInfo", "record_fs"), + ("DetectionModel", "input"), +} + + +def nested_models(annotation: Any) -> list[type[BaseModel]]: + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + return [annotation] + + return [model for arg in get_args(annotation) for model in nested_models(arg)] + + +def all_models( + model: type[BaseModel], seen: set[type[BaseModel]] | None = None +) -> list[type[BaseModel]]: + seen = set() if seen is None else seen + + if model in seen: + return [] + + seen.add(model) + found = [model] + + for field in model.model_fields.values(): + for nested in nested_models(field.annotation): + found += all_models(nested, seen) + + return found + + +def dict_key_types(annotation: Any) -> list[Any]: + keys = [get_args(annotation)[0]] if get_origin(annotation) is dict else [] + return keys + [key for arg in get_args(annotation) for key in dict_key_types(arg)] + + +class TestSchemaDiscipline(unittest.TestCase): + def test_every_field_has_a_description_and_public_flag(self): + for model in all_models(AnalyticsReport): + for name, field in model.model_fields.items(): + with self.subTest(field=f"{model.__name__}.{name}"): + self.assertTrue(field.description) + self.assertIsInstance(field.json_schema_extra, dict) + self.assertIsInstance(field.json_schema_extra.get("x-public"), bool) + + def test_strings_are_limited_to_ids_and_hardware_names(self): + for model in all_models(AnalyticsReport): + properties = model.model_json_schema()["properties"] + + for name, field in model.model_fields.items(): + if field.annotation is not str: + continue + + with self.subTest(field=f"{model.__name__}.{name}"): + self.assertIn((model.__name__, name), ALLOWED_STRINGS) + self.assertTrue( + "maxLength" in properties[name] or "pattern" in properties[name] + ) + + def test_map_keys_are_enums(self): + for model in all_models(AnalyticsReport): + for name, field in model.model_fields.items(): + for key_type in dict_key_types(field.annotation): + with self.subTest(field=f"{model.__name__}.{name}"): + self.assertTrue(issubclass(key_type, Enum)) + + +class TestEnumsTrackTheirSources(unittest.TestCase): + def test_hardware_keys_cover_every_probe(self): + probed = set( + re.findall( + r'_hardware\(\s*"([^"]+)"', inspect.getsource(detection_hardware) + ) + ) + + self.assertTrue(probed) + self.assertLessEqual(probed, {key.value for key in HardwareKey}) + + def test_decode_families_cover_every_hwaccel_family(self): + families = { + value.key + for value in vars(hwaccel).values() + if isinstance(value, HwaccelFamily) + } + + self.assertTrue(families) + self.assertLessEqual(families, {family.value for family in DecodeFamily}) + + def test_preset_keys_mirror_the_presets(self): + for enum, presets in ( + (HwaccelKey, PRESETS_HW_ACCEL_DECODE), + (InputPresetKey, PRESETS_INPUT), + ): + with self.subTest(enum=enum.__name__): + self.assertEqual( + {key.value for key in enum}, + {name.removeprefix("preset-") for name in presets} + | {"custom", "none"}, + ) + + def test_notice_keys_are_the_reportable_kinds(self): + self.assertEqual( + {key.value for key in NoticeKindKey}, + {key for key, kind in NOTICE_KINDS.items() if kind.reportable}, + ) + self.assertNotIn("analytics_prompt", {key.value for key in NoticeKindKey}) diff --git a/frigate/test/test_analytics_state.py b/frigate/test/test_analytics_state.py new file mode 100644 index 0000000000..24e45d3619 --- /dev/null +++ b/frigate/test/test_analytics_state.py @@ -0,0 +1,64 @@ +"""Tests for the analytics state file.""" + +import os +import tempfile +import unittest + +from frigate.analytics.state import ( + AnalyticsState, + delete_state, + load_state, + new_state, + save_state, +) + + +class TestAnalyticsState(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.addCleanup(self.dir.cleanup) + self.path = os.path.join(self.dir.name, ".analytics.json") + + def write(self, content: str) -> None: + with open(self.path, "w") as f: + f.write(content) + + def test_missing_file_loads_as_none(self): + self.assertIsNone(load_state(self.path)) + + def test_save_then_load_round_trips(self): + state = AnalyticsState(install_id="a" * 32, last_attempt_at=1790000000.5) + + self.assertTrue(save_state(state, self.path)) + self.assertEqual(load_state(self.path), state) + + def test_unusable_files_load_as_none(self): + for content in ( + "{not json", + '["a"]', + '{"install_id": "short", "last_attempt_at": 1}', + '{"install_id": "' + "a" * 32 + '"}', + '{"install_id": "' + "a" * 32 + '", "last_attempt_at": true}', + ): + with self.subTest(content=content): + self.write(content) + self.assertIsNone(load_state(self.path)) + + def test_save_reports_failure_instead_of_raising(self): + missing = os.path.join(self.dir.name, "missing", ".analytics.json") + + self.assertFalse(save_state(new_state(), missing)) + + def test_new_state_has_a_hex_id_and_no_attempt(self): + state = new_state() + + self.assertRegex(state.install_id, r"^[0-9a-f]{32}$") + self.assertEqual(state.last_attempt_at, 0.0) + + def test_delete_removes_the_file_and_tolerates_a_missing_one(self): + save_state(new_state(), self.path) + + delete_state(self.path) + delete_state(self.path) + + self.assertFalse(os.path.exists(self.path)) diff --git a/frigate/test/test_analytics_transport.py b/frigate/test/test_analytics_transport.py new file mode 100644 index 0000000000..ba75cdb706 --- /dev/null +++ b/frigate/test/test_analytics_transport.py @@ -0,0 +1,64 @@ +"""Tests for sending a report.""" + +import unittest +from unittest.mock import Mock, patch + +import requests + +from frigate.analytics.transport import TIMEOUT_S, SendOutcome, send_report +from frigate.version import VERSION + +URL = "https://example.test/report" + + +def post(status: int = 204, text: str = "", side_effect: Exception | None = None): + return patch( + "frigate.analytics.transport.requests.post", + return_value=Mock(status_code=status, text=text), + side_effect=side_effect, + ) + + +class TestSendReport(unittest.TestCase): + def test_posts_the_body_as_json_with_a_user_agent(self): + with post() as mock_post: + outcome = send_report(URL, '{"a":1}') + + self.assertIs(outcome, SendOutcome.accepted) + args, kwargs = mock_post.call_args + self.assertEqual(args[0], URL) + self.assertEqual(kwargs["data"], b'{"a":1}') + self.assertEqual(kwargs["headers"]["Content-Type"], "application/json") + self.assertEqual(kwargs["headers"]["User-Agent"], f"Frigate/{VERSION}") + self.assertFalse(kwargs["allow_redirects"]) + self.assertEqual(kwargs["timeout"], TIMEOUT_S) + + def test_maps_statuses_to_outcomes(self): + cases = ( + (200, SendOutcome.accepted), + (400, SendOutcome.rejected), + (429, SendOutcome.rate_limited), + (302, SendOutcome.failed), + (500, SendOutcome.failed), + ) + + for status, outcome in cases: + with self.subTest(status=status), post(status): + self.assertIs(send_report(URL, "{}"), outcome) + + def test_logs_the_rejection_reason(self): + with ( + post(400, "install_id: bad"), + self.assertLogs("frigate.analytics.transport", "DEBUG") as logs, + ): + send_report(URL, "{}") + + self.assertIn("install_id: bad", logs.output[0]) + + def test_a_network_error_fails_without_raising(self): + # the user can't act on a failed send, so it stays out of warnings + with ( + post(side_effect=requests.ConnectionError("down")), + self.assertNoLogs("frigate.analytics.transport", "WARNING"), + ): + self.assertIs(send_report(URL, "{}"), SendOutcome.failed) diff --git a/frigate/test/test_builtin.py b/frigate/test/test_builtin.py index 1391c0eec7..2de63d823f 100644 --- a/frigate/test/test_builtin.py +++ b/frigate/test/test_builtin.py @@ -1,9 +1,10 @@ """Tests for frigate.util.builtin helpers.""" import unittest +from multiprocessing import Value from unittest.mock import patch -from frigate.util.builtin import EventsPerSecond +from frigate.util.builtin import EventsPerSecond, InferenceSpeed class TestEventsPerSecond(unittest.TestCase): @@ -62,5 +63,17 @@ class TestEventsPerSecond(unittest.TestCase): self.assertAlmostEqual(eps.eps(), 20.0) +class TestInferenceSpeed(unittest.TestCase): + def test_first_inference_seeds_the_average(self) -> None: + metric = Value("d", 0.0) + speed = InferenceSpeed(metric) + + speed.update(0.02) + self.assertEqual(metric.value, 0.02) + + speed.update(0.12) + self.assertAlmostEqual(metric.value, 0.03) + + if __name__ == "__main__": unittest.main() diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index bc9d08afdd..e915758d27 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -18,6 +18,11 @@ from frigate.util.builtin import deep_merge class TestConfig(unittest.TestCase): + def test_analytics_is_off_by_default(self): + frigate_config = FrigateConfig(**self.minimal) + + self.assertFalse(frigate_config.telemetry.analytics) + def setUp(self): self.minimal = { "mqtt": {"host": "mqtt"}, diff --git a/frigate/test/test_config_util.py b/frigate/test/test_config_util.py index 71d1eccdfd..279f66fc06 100644 --- a/frigate/test/test_config_util.py +++ b/frigate/test/test_config_util.py @@ -83,5 +83,30 @@ class TestSwapRuntimeConfig(unittest.TestCase): app.genai_manager.update_config.assert_called_once_with(config) +class TestConfigHolder(unittest.TestCase): + def test_set_passes_the_new_config_to_subscribers(self) -> None: + holder = ConfigHolder(MagicMock(name="boot_config")) + listener = MagicMock() + holder.subscribe(listener) + config = MagicMock(name="new_config") + + holder.set(config) + + listener.assert_called_once_with(config) + + def test_a_failing_subscriber_does_not_block_the_swap(self) -> None: + holder = ConfigHolder(MagicMock(name="boot_config")) + after = MagicMock() + holder.subscribe(MagicMock(side_effect=RuntimeError("boom"))) + holder.subscribe(after) + config = MagicMock(name="new_config") + + with self.assertLogs("frigate.config.holder", "ERROR"): + holder.set(config) + + self.assertIs(holder.config, config) + after.assert_called_once_with(config) + + if __name__ == "__main__": unittest.main() diff --git a/frigate/test/test_notice_registry.py b/frigate/test/test_notice_registry.py index 8619d9177f..0bc94f2841 100644 --- a/frigate/test/test_notice_registry.py +++ b/frigate/test/test_notice_registry.py @@ -46,6 +46,16 @@ class TestNoticeKinds(unittest.TestCase): self.assertIsNone(kind.link_for({"version": "0.19.1"})) + def test_analytics_prompt_links_to_telemetry_settings(self): + kind = NOTICE_KINDS["analytics_prompt"] + + self.assertEqual(kind.link_for({}), "/settings?page=systemTelemetry") + self.assertFalse(kind.counts_repeats) + self.assertFalse(kind.reportable) + + def test_update_available_is_not_reported(self): + self.assertFalse(NOTICE_KINDS["update_available"].reportable) + class RegistryTestCase(unittest.TestCase): def setUp(self): @@ -347,6 +357,41 @@ class TestNoticeRegistry(RegistryTestCase): self.assertEqual(stats["skipped_detections"]["mutes"], 1) +class TestAnalyticsBaseline(RegistryTestCase): + def test_reset_counts_from_now_and_new_kinds_start_at_zero(self): + stuck = {"scope": "ov", "params": {"detector": "ov"}} + self.registry.raise_notice("detector_stuck", **stuck) + self.registry.raise_notice("detector_stuck", **stuck) + + self.registry.reset_analytics_baseline() + self.registry.raise_notice("detector_stuck", **stuck) + self.registry.raise_notice( + "skipped_detections", scope="front_door", params={"pct": 12.5} + ) + + rows = {row["kind"]: row for row in self.registry.stats()} + self.assertEqual( + ( + rows["detector_stuck"]["occurrences"], + rows["detector_stuck"]["reported_occurrences"], + ), + (3, 2), + ) + self.assertEqual(rows["skipped_detections"]["reported_occurrences"], 0) + + @patch.dict(NOTICE_KINDS, TEST_KINDS) + def test_reset_puts_held_repeats_in_the_baseline(self): + for _ in range(3): + self.registry.raise_notice("batched") + + self.registry.reset_analytics_baseline() + self.registry.flush() + + [row] = self.registry.stats() + self.assertEqual((row["occurrences"], row["reported_occurrences"]), (3, 3)) + self.assertEqual(self.registry.active()[0]["count"], 3) + + class TestApply(RegistryTestCase): def test_each_action_reaches_its_method(self): self.registry.apply( diff --git a/generate_analytics_schema.py b/generate_analytics_schema.py new file mode 100644 index 0000000000..1fa52fa7c9 --- /dev/null +++ b/generate_analytics_schema.py @@ -0,0 +1,61 @@ +"""Generate the analytics report JSON Schema from the Pydantic models. + +The committed docs/static/frigate-analytics-schema.json documents every field +for the ingest Worker's aggregation job and the docs page. It is exact for this +version only, so the Worker validates just the envelope against it. Never edit +it by hand, and never run a formatter over it. + +Usage (from the repository root): + + python3 generate_analytics_schema.py # write the schema + python3 generate_analytics_schema.py --check # CI guard: fail if stale +""" + +import argparse +import json +import sys +from pathlib import Path + +from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport + +OUTPUT = Path(__file__).parent / "docs" / "static" / "frigate-analytics-schema.json" + + +def render() -> str: + """The JSON Schema for the current report models.""" + schema = AnalyticsReport.model_json_schema() + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + schema["$id"] = "https://docs.frigate.video/frigate-analytics-schema.json" + schema["x-schema-version"] = SCHEMA_VERSION + return json.dumps(schema, indent=2, sort_keys=True) + "\n" + + +def main() -> int: + """Write or check the schema file; returns the process exit code.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--check", action="store_true", help="fail if the committed schema is stale" + ) + args = parser.parse_args() + rendered = render() + + if args.check: + current = OUTPUT.read_text() if OUTPUT.exists() else "" + + if current != rendered: + print( + f"{OUTPUT} is out of date, run python3 generate_analytics_schema.py", + file=sys.stderr, + ) + return 1 + + print(f"{OUTPUT} is up to date") + return 0 + + OUTPUT.write_text(rendered) + print(f"Wrote {OUTPUT}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/generate_api_auth_spec.py b/generate_api_auth_spec.py index 6b5b8b0eaf..da90dce274 100644 --- a/generate_api_auth_spec.py +++ b/generate_api_auth_spec.py @@ -47,8 +47,8 @@ from fastapi.routing import APIRoute from ruamel.yaml import YAML from ruamel.yaml.scalarstring import LiteralScalarString -from frigate.api import app as main_app from frigate.api import ( + analytics, auth, camera, chat, @@ -65,6 +65,7 @@ from frigate.api import ( record, review, ) +from frigate.api import app as main_app from frigate.api.auth import require_admin_by_default logging.basicConfig(level=logging.INFO, format="%(message)s") @@ -138,6 +139,7 @@ ACCESS_NOTES = { # Mirrors the router set wired up in frigate.api.fastapi_app. ROUTERS = [ + analytics.router, auth.router, camera.router, chat.router, diff --git a/web/e2e/fixtures/mock-data/config-schema.json b/web/e2e/fixtures/mock-data/config-schema.json index 6013c1843d..de78eb8ee9 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"}, "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": "auto", "description": "Language code used for transcription/translation (for example 'en' for English), or 'auto' to let the model detect it. See https://whisper-api.com/docs/languages/ for supported language codes.", "title": "Transcription language", "type": "string"}, "model": {"anyOf": [{"$ref": "#/$defs/AudioTranscriptionModelEnum"}, {"type": "string"}, {"type": "null"}], "default": "whisper", "description": "The transcription backend: 'whisper' for Frigate's built-in local models, or the name of a GenAI provider with the transcribe role.", "title": "Audio transcription model or GenAI provider name"}, "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"}, "AudioTranscriptionModelEnum": {"enum": ["whisper"], "title": "AudioTranscriptionModelEnum", "type": "string"}, "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": "Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS.", "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": {"default": ["all_objects"], "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": {"default": ["all_objects"], "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"}, "transcode": {"$ref": "#/$defs/LiveTranscodeConfig", "description": "Lower-quality live streams transcoded on demand by go2rtc.", "title": "Transcoded streams"}, "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": {"default": "default", "description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'.", "pattern": "^[A-Za-z0-9_-]+$", "title": "Detect scene", "type": "string"}, "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, transcribe); one provider per role. Only chat, descriptions, and embeddings are granted by default; transcribe must be listed explicitly.", "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"}, "frame_mode": {"$ref": "#/$defs/ReviewFrameModeEnum", "default": "frames", "description": "How frames are presented to the model. 'frames' sends the prompt followed by the frames, which suits models that track a sequence well on their own. 'annotated_frames' labels each frame and interleaves notes derived from object tracking, which helps models that lose track of activity that repeats or reverses.", "title": "Frame mode"}, "response_style": {"$ref": "#/$defs/ReviewResponseStyleEnum", "default": "default", "description": "Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged.", "title": "Response style"}, "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", "transcribe"], "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"}, "LiveTranscodeConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Add lower-quality live streams that go2rtc transcodes in real time while someone is watching.", "title": "Enable transcoded streams", "type": "boolean"}, "source": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "go2rtc stream to transcode. Defaults to the first live stream.", "title": "Source stream"}, "qualities": {"description": "One transcoded stream is added per quality.", "items": {"$ref": "#/$defs/LiveTranscodeQualityConfig"}, "title": "Qualities", "type": "array"}}, "title": "LiveTranscodeConfig", "type": "object"}, "LiveTranscodeQualityConfig": {"additionalProperties": false, "properties": {"height": {"description": "Output height in pixels; width follows the source aspect ratio.", "maximum": 2160, "minimum": 144, "title": "Height", "type": "integer"}, "bitrate": {"description": "Target and maximum video bitrate in kbps.", "minimum": 64, "title": "Bitrate", "type": "integer"}}, "required": ["height", "bitrate"], "title": "LiveTranscodeQualityConfig", "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": {"default": "default", "description": "A name for the camera environment this model is used for, such as 'thermal'. Cameras select a model by setting detect.scene to a matching value, and the 'default' model is used by any camera that does not set one.", "pattern": "^[A-Za-z0-9_-]+$", "title": "Model scene", "type": "string"}, "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"], "truck": ["license_plate"], "garbage_truck": ["license_plate"], "motorcycle": ["license_plate"], "bus": ["license_plate"], "school_bus": ["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"}, "ttl": {"$ref": "#/$defs/NotificationTTLEnum", "default": "immediate", "description": "How the push service handles a notification sent while an alert is active if your device can't be reached. Immediate delivers it right away or drops it. Reliable keeps retrying for up to 30 seconds. The notification sent when an alert ends is always kept for up to an hour.", "title": "Notification TTL"}, "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"}, "NotificationTTLEnum": {"enum": ["immediate", "reliable"], "title": "NotificationTTLEnum", "type": "string"}, "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. Set to 'none' to deny access to unmapped users.", "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.", "minimum": 1, "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, "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"}, "ReviewFrameModeEnum": {"description": "How review frames are presented to the GenAI provider.", "enum": ["frames", "annotated_frames"], "title": "ReviewFrameModeEnum", "type": "string"}, "ReviewResponseStyleEnum": {"description": "Writing style presets for GenAI review descriptions.", "enum": ["default", "natural", "concise", "detailed"], "title": "ReviewResponseStyleEnum", "type": "string"}, "ReviewRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 10, "description": "Number of days to retain recordings of detection events.", "minimum": 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"}, "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, falling back to the 'default' model.", "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": "auto", "description": "Language code used for transcription/translation (for example 'en' for English), or 'auto' to let the model detect it. See https://whisper-api.com/docs/languages/ for supported language codes.", "title": "Transcription language", "type": "string"}, "model": {"anyOf": [{"$ref": "#/$defs/AudioTranscriptionModelEnum"}, {"type": "string"}, {"type": "null"}], "default": "whisper", "description": "The transcription backend: 'whisper' for Frigate's built-in local models, or the name of a GenAI provider with the transcribe role.", "title": "Audio transcription model or GenAI provider name"}, "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"}, "AudioTranscriptionModelEnum": {"enum": ["whisper"], "title": "AudioTranscriptionModelEnum", "type": "string"}, "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": "Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS.", "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": {"default": ["all_objects"], "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": {"default": ["all_objects"], "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"}, "transcode": {"$ref": "#/$defs/LiveTranscodeConfig", "description": "Lower-quality live streams transcoded on demand by go2rtc.", "title": "Transcoded streams"}, "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": {"default": "default", "description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'.", "pattern": "^[A-Za-z0-9_-]+$", "title": "Detect scene", "type": "string"}, "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, transcribe); one provider per role. Only chat, descriptions, and embeddings are granted by default; transcribe must be listed explicitly.", "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"}, "frame_mode": {"$ref": "#/$defs/ReviewFrameModeEnum", "default": "frames", "description": "How frames are presented to the model. 'frames' sends the prompt followed by the frames, which suits models that track a sequence well on their own. 'annotated_frames' labels each frame and interleaves notes derived from object tracking, which helps models that lose track of activity that repeats or reverses.", "title": "Frame mode"}, "response_style": {"$ref": "#/$defs/ReviewResponseStyleEnum", "default": "default", "description": "Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged.", "title": "Response style"}, "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", "transcribe"], "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"}, "LiveTranscodeConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Add lower-quality live streams that go2rtc transcodes in real time while someone is watching.", "title": "Enable transcoded streams", "type": "boolean"}, "source": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "go2rtc stream to transcode. Defaults to the first live stream.", "title": "Source stream"}, "qualities": {"description": "One transcoded stream is added per quality.", "items": {"$ref": "#/$defs/LiveTranscodeQualityConfig"}, "title": "Qualities", "type": "array"}}, "title": "LiveTranscodeConfig", "type": "object"}, "LiveTranscodeQualityConfig": {"additionalProperties": false, "properties": {"height": {"description": "Output height in pixels; width follows the source aspect ratio.", "maximum": 2160, "minimum": 144, "title": "Height", "type": "integer"}, "bitrate": {"description": "Target and maximum video bitrate in kbps.", "minimum": 64, "title": "Bitrate", "type": "integer"}}, "required": ["height", "bitrate"], "title": "LiveTranscodeQualityConfig", "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": {"default": "default", "description": "A name for the camera environment this model is used for, such as 'thermal'. Cameras select a model by setting detect.scene to a matching value, and the 'default' model is used by any camera that does not set one.", "pattern": "^[A-Za-z0-9_-]+$", "title": "Model scene", "type": "string"}, "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"], "truck": ["license_plate"], "garbage_truck": ["license_plate"], "motorcycle": ["license_plate"], "bus": ["license_plate"], "school_bus": ["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"}, "ttl": {"$ref": "#/$defs/NotificationTTLEnum", "default": "immediate", "description": "How the push service handles a notification sent while an alert is active if your device can't be reached. Immediate delivers it right away or drops it. Reliable keeps retrying for up to 30 seconds. The notification sent when an alert ends is always kept for up to an hour.", "title": "Notification TTL"}, "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"}, "NotificationTTLEnum": {"enum": ["immediate", "reliable"], "title": "NotificationTTLEnum", "type": "string"}, "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. Set to 'none' to deny access to unmapped users.", "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.", "minimum": 1, "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, "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"}, "ReviewFrameModeEnum": {"description": "How review frames are presented to the GenAI provider.", "enum": ["frames", "annotated_frames"], "title": "ReviewFrameModeEnum", "type": "string"}, "ReviewResponseStyleEnum": {"description": "Writing style presets for GenAI review descriptions.", "enum": ["default", "natural", "concise", "detailed"], "title": "ReviewResponseStyleEnum", "type": "string"}, "ReviewRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 10, "description": "Number of days to retain recordings of detection events.", "minimum": 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"}, "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": {"analytics": {"default": false, "description": "Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on.", "title": "Share anonymous analytics", "type": "boolean"}, "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, falling back to the 'default' model.", "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 a0c48b40c1..d5f8baf6a8 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": "default", "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"], "truck": ["license_plate"], "garbage_truck": ["license_plate"], "motorcycle": ["license_plate"], "bus": ["license_plate"], "school_bus": ["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": "default", "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": "preset-vaapi", "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"}, "transcode": {"enabled": false, "source": null, "qualities": [{"height": 720, "bitrate": 1200}, {"height": 480, "bitrate": 500}, {"height": 360, "bitrate": 250}]}, "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": "preset-vaapi", "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, "frame_mode": "frames", "response_style": "default", "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": "default", "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": "preset-vaapi", "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"}, "transcode": {"enabled": false, "source": null, "qualities": [{"height": 720, "bitrate": 1200}, {"height": 480, "bitrate": 500}, {"height": 360, "bitrate": 250}]}, "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": "preset-vaapi", "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, "frame_mode": "frames", "response_style": "default", "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": "default", "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": "preset-vaapi", "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"}, "transcode": {"enabled": false, "source": null, "qualities": [{"height": 720, "bitrate": 1200}, {"height": 480, "bitrate": 500}, {"height": 360, "bitrate": 250}]}, "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": "preset-vaapi", "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, "frame_mode": "frames", "response_style": "default", "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": "default", "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": "preset-vaapi", "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": [], "transcode": {"enabled": false, "source": null, "qualities": [{"height": 720, "bitrate": 1200}, {"height": 480, "bitrate": 500}, {"height": 360, "bitrate": 250}]}, "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": "preset-vaapi", "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, "frame_mode": "frames", "response_style": "default", "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": "auto", "model": "whisper", "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, "ttl": "immediate", "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": {"analytics": false, "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": "default", "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"], "truck": ["license_plate"], "garbage_truck": ["license_plate"], "motorcycle": ["license_plate"], "bus": ["license_plate"], "school_bus": ["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": "default", "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": "preset-vaapi", "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"}, "transcode": {"enabled": false, "source": null, "qualities": [{"height": 720, "bitrate": 1200}, {"height": 480, "bitrate": 500}, {"height": 360, "bitrate": 250}]}, "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": "preset-vaapi", "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, "frame_mode": "frames", "response_style": "default", "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, "ttl": "immediate", "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": "default", "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": "preset-vaapi", "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"}, "transcode": {"enabled": false, "source": null, "qualities": [{"height": 720, "bitrate": 1200}, {"height": 480, "bitrate": 500}, {"height": 360, "bitrate": 250}]}, "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": "preset-vaapi", "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, "frame_mode": "frames", "response_style": "default", "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, "ttl": "immediate", "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": "default", "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": "preset-vaapi", "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"}, "transcode": {"enabled": false, "source": null, "qualities": [{"height": 720, "bitrate": 1200}, {"height": 480, "bitrate": 500}, {"height": 360, "bitrate": 250}]}, "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": "preset-vaapi", "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, "frame_mode": "frames", "response_style": "default", "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, "ttl": "immediate", "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": "default", "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": "preset-vaapi", "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": [], "transcode": {"enabled": false, "source": null, "qualities": [{"height": 720, "bitrate": 1200}, {"height": 480, "bitrate": 500}, {"height": 360, "bitrate": 250}]}, "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": "preset-vaapi", "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, "frame_mode": "frames", "response_style": "default", "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": "auto", "model": "whisper", "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/specs/settings/telemetry-analytics.spec.ts b/web/e2e/specs/settings/telemetry-analytics.spec.ts new file mode 100644 index 0000000000..0fc58c395b --- /dev/null +++ b/web/e2e/specs/settings/telemetry-analytics.spec.ts @@ -0,0 +1,54 @@ +/** + * Telemetry settings: the analytics preview shows the report Frigate would + * send, fetched only when opened. + */ + +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test, expect } from "../../fixtures/frigate-test"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CONFIG_SCHEMA = JSON.parse( + readFileSync( + resolve(__dirname, "../../fixtures/mock-data/config-schema.json"), + "utf-8", + ), +); + +const PREVIEW = { + schema_version: 1, + install_id: "0".repeat(32), + install: { version: "0.19.0-test" }, +}; + +test.describe("telemetry settings: analytics preview @medium", () => { + test("shows the report on demand", async ({ frigateApp }) => { + let requests = 0; + const page = frigateApp.page; + + await page.route("**/api/config/schema.json", (route) => + route.fulfill({ json: CONFIG_SCHEMA }), + ); + await page.route("**/api/config/raw_paths", (route) => + route.fulfill({ json: { telemetry: {} } }), + ); + await page.route("**/api/analytics/preview", (route) => { + requests += 1; + return route.fulfill({ json: PREVIEW }); + }); + + await frigateApp.goto("/settings?page=systemTelemetry"); + + const button = page.getByRole("button", { name: "Preview the report" }); + await expect(button).toBeVisible(); + expect(requests).toBe(0); + + await button.click(); + + await expect(page.getByTestId("analytics-preview")).toContainText( + '"schema_version": 1', + ); + expect(requests).toBe(1); + }); +}); diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index b9ba8f2044..945f9003ce 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -226,6 +226,10 @@ "telemetry": { "label": "Telemetry", "description": "System telemetry and stats options including GPU and network bandwidth monitoring.", + "analytics": { + "label": "Share anonymous analytics", + "description": "Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on." + }, "network_interfaces": { "label": "Network interfaces", "description": "List of network interface name prefixes to monitor for bandwidth statistics." diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index a759f394c5..adb840d5ae 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1,4 +1,9 @@ { + "analyticsPreview": { + "show": "Preview the report", + "desc": "Frigate sends this report once a day while sharing is on. It never includes camera names, zones, labels, or addresses.", + "error": "Unable to build the preview" + }, "documentTitle": { "default": "Settings - Frigate", "authentication": "Authentication Settings - Frigate", diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index fbaf1d7224..f69b90637d 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -56,7 +56,8 @@ "object_processing_behind": "Tracked object processing could not keep up, so camera frames were dropped. Check CPU utilization, and whether CPU limits or pinning on the container or VM are leaving Frigate too little CPU", "failed_login_one": "Failed login attempt for {{user}}", "failed_login_other": "Failed login attempts for {{user}}", - "update_available": "Frigate {{version}} is available" + "update_available": "Frigate {{version}} is available", + "analytics_prompt": "Consider sharing anonymous usage statistics to help the developers improve Frigate" }, "streamPrefix": "Stream {{index}}: {{message}}", "streamPrefixRestream": "Stream {{index}} (via go2rtc): {{message}}", diff --git a/web/src/components/config-form/section-configs/telemetry.ts b/web/src/components/config-form/section-configs/telemetry.ts index 43b3387446..e729bb9271 100644 --- a/web/src/components/config-form/section-configs/telemetry.ts +++ b/web/src/components/config-form/section-configs/telemetry.ts @@ -3,9 +3,17 @@ import type { SectionConfigOverrides } from "./types"; const telemetry: SectionConfigOverrides = { base: { sectionDocs: "/configuration/advanced/reference", + fieldDocs: { + analytics: "/configuration/advanced/analytics", + }, restartRequired: ["version_check"], - fieldOrder: ["network_interfaces", "stats", "version_check"], + fieldOrder: ["analytics", "network_interfaces", "stats", "version_check"], advancedFields: [], + uiSchema: { + analytics: { + "ui:after": { render: "AnalyticsPreview" }, + }, + }, }, }; diff --git a/web/src/components/config-form/sectionExtras/AnalyticsPreview.tsx b/web/src/components/config-form/sectionExtras/AnalyticsPreview.tsx new file mode 100644 index 0000000000..5227810a52 --- /dev/null +++ b/web/src/components/config-form/sectionExtras/AnalyticsPreview.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import useSWR from "swr"; +import { useTranslation } from "react-i18next"; +import { LuChevronDown, LuChevronRight } from "react-icons/lu"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +export default function AnalyticsPreview() { + const { t } = useTranslation("views/settings"); + const [open, setOpen] = useState(false); + + // built on demand, since building runs every collector on the server + const { data, error } = useSWR>( + open ? "analytics/preview" : null, + { revalidateOnFocus: false }, + ); + + return ( + + + + +
+ {t("analyticsPreview.desc")} +
+ + {error ? ( +
+ {t("analyticsPreview.error")} +
+ ) : data ? ( +
+            {JSON.stringify(data, null, 2)}
+          
+ ) : ( + + )} +
+
+ ); +} diff --git a/web/src/components/config-form/sectionExtras/registry.ts b/web/src/components/config-form/sectionExtras/registry.ts index 26cd48dbdb..3493871d35 100644 --- a/web/src/components/config-form/sectionExtras/registry.ts +++ b/web/src/components/config-form/sectionExtras/registry.ts @@ -1,4 +1,5 @@ import type { ComponentType } from "react"; +import AnalyticsPreview from "./AnalyticsPreview"; import SemanticSearchReindex from "./SemanticSearchReindex.tsx"; import CameraReviewStatusToggles from "./CameraReviewStatusToggles"; import ProxyRoleMap from "./ProxyRoleMap"; @@ -56,6 +57,9 @@ export const sectionRenderers: SectionRenderers = { birdseye: { BirdseyeCameraReorder, }, + telemetry: { + AnalyticsPreview, + }, }; export default sectionRenderers; diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index ce93c12c9e..bc8017ed87 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -662,6 +662,7 @@ export interface FrigateConfig { }; telemetry: { + analytics: boolean; network_interfaces: string[]; stats: { amd_gpu_stats: boolean;