diff --git a/docs/data/object_detectors_models.yaml b/docs/data/object_detectors_models.yaml index a4458c3091..4b0cad54bf 100644 --- a/docs/data/object_detectors_models.yaml +++ b/docs/data/object_detectors_models.yaml @@ -1149,6 +1149,11 @@ rknn: | **Model Input D Type** | `int` (Frigate's default value) | | **Object Detection Model Type** | `yolo-generic` | yaml: |- + detectors: + rknn: + type: rknn + num_cores: 0 + model: # required # name of model (will be automatically downloaded) or path to your own .rknn model file # possible values are: @@ -1187,6 +1192,11 @@ rknn: | **Model Input D Type** | `int` (Frigate's default value) | | **Object Detection Model Type** | `yolonas` | yaml: |- + detectors: + rknn: + type: rknn + num_cores: 0 + model: # required # name of model (will be automatically downloaded) or path to your own .rknn model file # possible values are: @@ -1222,6 +1232,11 @@ rknn: | **Model Input D Type** | `int` (Frigate's default value) | | **Object Detection Model Type** | `yolox` | yaml: |- + detectors: + rknn: + type: rknn + num_cores: 0 + model: # required # name of model (will be automatically downloaded) or path to your own .rknn model file # possible values are: @@ -1297,11 +1312,12 @@ degirumAiServer: | **Zoo** | `degirum/public` | | **Token** | your AI Hub token (optional for the public zoo) | yaml: | - degirum_detector: - type: degirum - location: degirum - zoo: degirum/public - token: dg_example_token + detectors: + degirum_detector: + type: degirum + location: degirum + zoo: degirum/public + token: dg_example_token degirumLocal: title: DeGirum Local models: @@ -1318,11 +1334,12 @@ degirumLocal: | **Zoo** | `degirum/public` | | **Token** | your AI Hub token (optional for the public zoo) | yaml: | - degirum_detector: - type: degirum - location: @local - zoo: degirum/public - token: dg_example_token + detectors: + degirum_detector: + type: degirum + location: "@local" + zoo: degirum/public + token: dg_example_token degirumCloud: title: DeGirum AI Hub Cloud models: @@ -1339,8 +1356,9 @@ degirumCloud: | **Zoo** | `degirum/public` | | **Token** | your AI Hub token (optional for the public zoo) | yaml: | - degirum_detector: - type: degirum - location: @cloud - zoo: degirum/public - token: dg_example_token + detectors: + degirum_detector: + type: degirum + location: "@cloud" + zoo: degirum/public + token: dg_example_token diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index eae92a9815..b385ecbe60 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -4,6 +4,7 @@ title: License Plate Recognition (LPR) --- import ConfigTabs from "@site/src/components/ConfigTabs"; +import FrigateConfigMock from "@site/src/components/FrigateConfigMock"; import TabItem from "@theme/TabItem"; import NavPath from "@site/src/components/NavPath"; import FaqItem from "@site/src/components/FaqItem"; @@ -50,9 +51,11 @@ License plate recognition is disabled by default and must be enabled before it c -Navigate to . - -- Set **Enable LPR** to on + @@ -70,7 +73,17 @@ Like other enrichments in Frigate, LPR **must be enabled globally** to use the f -Navigate to for the desired camera and disable the **Enable LPR** toggle. + @@ -99,16 +112,40 @@ Fine-tune the LPR feature using these optional parameters. The only optional par -Navigate to . - -- **Detection threshold**: License plate object detection confidence score required before recognition runs. This field only applies to the standalone license plate detection model; `threshold` and `min_score` object filters should be used for models like Frigate+ that have license plate detection built in. - - Default: `0.7` -- **Minimum plate area**: Minimum area (in pixels) a license plate must be before recognition runs. This is an _area_ measurement (length x width). For reference, 1000 pixels represents a ~32x32 pixel square in your camera image. Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates. - - Default: `1000` pixels -- **Device**: Device to use to run license plate detection _and_ recognition models. Auto-selected by Frigate and can be `CPU`, `GPU`, or the GPU's device number. For users without a model that detects license plates natively, using a GPU may increase performance of the YOLOv9 license plate detector model. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation. - - Default: `None` -- **Model size**: The size of the model used to identify regions of text on plates. The `small` model is fast and identifies groups of Latin and Chinese characters. The `large` model identifies Latin characters only, and uses an enhanced text detector to find characters on multi-line plates. If your country or region does not use multi-line plates, you should use the `small` model. - - Default: `small` + @@ -130,12 +167,34 @@ lpr: -Navigate to . - -- **Recognition threshold**: Recognition confidence score required to add the plate to the object as a `recognized_license_plate` and/or `sub_label`. - - Default: `0.9` -- **Min plate length**: Minimum number of characters a detected license plate must have to be added as a `recognized_license_plate` and/or `sub_label`. Use this to filter out short, incomplete, or incorrect detections. -- **Plate format regex**: A regular expression defining the expected format of detected plates. Plates that do not match this format will be discarded. Websites like https://regex101.com/ can help test regular expressions for your plates. + @@ -156,10 +215,31 @@ lpr: -Navigate to . - -- **Known plates**: Assign custom `sub_label` values to `car` and `motorcycle` objects when a recognized plate matches a known value. These labels appear in the UI, filters, and notifications. Unknown plates are still saved but are added to the `recognized_license_plate` field rather than the `sub_label`. -- **Match distance**: Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate. For example, setting to `1` allows a plate `ABCDE` to match `ABCBE` or `ABCD`. This parameter will _not_ operate on known plates that are defined as regular expressions. + @@ -183,10 +263,19 @@ lpr: -Navigate to . - -- **Enhancement level**: A value between 0 and 10 that adjusts the level of image enhancement applied to captured license plates before they are processed for recognition. Higher values increase contrast, sharpen details, and reduce noise, but excessive enhancement can blur or distort characters. This setting is best adjusted at the camera level if running LPR on multiple cameras. - - Default: `0` (no enhancement) + @@ -207,17 +296,27 @@ If Frigate is already recognizing plates correctly, leave enhancement at the def -Navigate to . - -Under **Replacement rules**, add regex rules to normalize detected plate strings before matching. Rules fire in order. For example: - -| Pattern | Replacement | Description | -| ---------------- | ----------- | -------------------------------------------------- | -| `[%#*?]` | _(empty)_ | Remove noise symbols | -| `[= ]` | `-` | Normalize `=` or space to dash | -| `O` | `0` | Swap `O` to `0` (common OCR error) | -| `I` | `1` | Swap `I` to `1` | -| `(\w{3})(\w{3})` | `\1-\2` | Split 6 chars into groups (e.g., ABC123 → ABC-123) | + @@ -252,9 +351,19 @@ These rules must be defined at the global level of your `lpr` config. -Navigate to . - -- **Save debug plates**: Set to on to save captured text on plates for debugging. These images are stored in `/media/frigate/clips/lpr`, organized into subdirectories by `/`, and named based on the capture timestamp. + @@ -279,17 +388,35 @@ These configuration parameters are available at the global level. The only optio -Navigate to . - -| Field | Description | -| ------------------------------ | ----------------------------------------------------------------------------------------------------- | -| **Enable LPR** | Set to on | -| **Minimum plate area** | Set to `1500` to ignore plates with an area (length x width) smaller than 1500 pixels | -| **Min plate length** | Set to `4` to only recognize plates with 4 or more characters | -| **Known plates > Wife's Car** | `ABC-1234`, `ABC-I234` (accounts for potential confusion between the number one and capital letter I) | -| **Known plates > Johnny** | `J*N-*234` (matches JHN-1234 and JMN-I234; `*` matches any number of characters) | -| **Known plates > Sally** | `[S5]LL 1234` (matches both SLL 1234 and 5LL 1234) | -| **Known plates > Work Trucks** | `EMP-[0-9]{3}[A-Z]` (matches plates like EMP-123A, EMP-456Z) | + @@ -321,7 +448,18 @@ If a camera is configured to detect `car` or `motorcycle` but you don't want Fri -Navigate to for the desired camera and disable the **Enable LPR** toggle. + @@ -362,48 +500,93 @@ An example configuration for a dedicated LPR camera using a `license_plate`-dete -Navigate to and set **Enable LPR** to on. Set **Device** to `CPU` (can also be `GPU` if available). - -Navigate to and add your camera streams. - -Navigate to . - -| Field | Description | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| **Enable object detection** | Set to on | -| **Detect FPS** | Set to `5`. Increase to `10` if vehicles move quickly across your frame. Higher than 10 is unnecessary and is not recommended. | -| **Minimum initialization frames** | Set to `2` | -| **Detect width** | Set to `1920` | -| **Detect height** | Set to `1080` | - -Navigate to . - -| Field | Description | -| ---------------------------------------------- | ------------------- | -| **Objects to track** | Add `license_plate` | -| **Object filters > License Plate > Threshold** | Set to `0.7` | - -Navigate to . - -| Field | Description | -| -------------------- | --------------------------------------------------------------------- | -| **Motion threshold** | Set to `30` | -| **Contour area** | Set to `60`. Use an increased value to tune out small motion changes. | -| **Improve contrast** | Set to off | - -Also add a motion mask over your camera's timestamp so it is not incorrectly detected as a license plate. - -Navigate to . - -| Field | Description | -| -------------------- | -------------------------------------------------------- | -| **Enable recording** | Set to on. Disable recording if you only want snapshots. | - -Navigate to . - -| Field | Description | -| -------------------- | ----------- | -| **Enable snapshots** | Set to on | + @@ -467,54 +650,104 @@ An example configuration for a dedicated LPR camera using the secondary pipeline -Navigate to and set **Enable LPR** to on. Set **Device** to `CPU` (can also be `GPU` if available and the correct Docker image is used). Set **Detection threshold** to `0.7` (change if necessary). - -Navigate to for your dedicated LPR camera. - -| Field | Description | -| --------------------- | -------------------------------------------------------------------------------- | -| **Enable LPR** | Set to on | -| **Enhancement level** | Set to `3` (optional, enhances the image before trying to recognize characters) | - -Navigate to and add your camera streams. - -Navigate to . - -| Field | Description | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| **Enable object detection** | Set to off to disable Frigate's standard object detection pipeline | -| **Detect FPS** | Set to `5`. Increase if necessary, though high values may slow down Frigate's enrichments pipeline and use considerable CPU. | -| **Detect width** | Set to `1920` (recommended value, but depends on your camera) | -| **Detect height** | Set to `1080` (recommended value, but depends on your camera) | - -Navigate to . - -| Field | Description | -| -------------------- | -------------------------------------------------------------------------------------- | -| **Objects to track** | Set to an empty list, required when not using a Frigate+ model for dedicated LPR mode | - -Navigate to . - -| Field | Description | -| -------------------- | --------------------------------------------------------------------- | -| **Motion threshold** | Set to `30` | -| **Contour area** | Set to `60`. Use an increased value to tune out small motion changes. | -| **Improve contrast** | Set to off | - -Navigate to and add a motion mask over your camera's timestamp so it is not incorrectly detected as a license plate. - -Navigate to . - -| Field | Description | -| -------------------- | -------------------------------------------------------- | -| **Enable recording** | Set to on. Disable recording if you only want snapshots. | - -Navigate to . - -| Field | Description | -| ----------------------------------------- | --------------- | -| **Detections config > Enable detections** | Set to on | -| **Detections config > Retain > Default** | Set to `7` days | + @@ -655,11 +888,13 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is -Navigate to . - -- Set **Enable LPR** to on -- Set **Device** to `CPU` -- Set **Save debug plates** to on + diff --git a/docs/docs/configuration/masks.md b/docs/docs/configuration/masks.md index 50c9fffc31..d0e3cfb83b 100644 --- a/docs/docs/configuration/masks.md +++ b/docs/docs/configuration/masks.md @@ -5,7 +5,7 @@ title: Masks import ConfigTabs from "@site/src/components/ConfigTabs"; import TabItem from "@theme/TabItem"; -import NavPath from "@site/src/components/NavPath"; +import FrigateConfigMock from "@site/src/components/FrigateConfigMock"; Frigate has two kinds of masks: motion masks and object filter masks. Both are narrow tools for fine-tuning, **not for hiding an area from Frigate**. Masks should be used sparingly; in most cases where users reach for one, a [zone](zones.md) with [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones) is the right tool instead. See [Which tool do I need?](#which-tool-do-i-need) and [Common mistakes](#common-mistakes) below if you're new to Frigate's mask behavior. @@ -25,19 +25,64 @@ Object filter masks can be used to filter out stubborn false positives in fixed ## Which tool do I need? -| What you're trying to do | Recommended tool | How it works | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Only get alerts/detections for activity in the areas you care about, ignoring activity elsewhere (e.g., alert when someone enters your yard, but not when they walk past on the sidewalk) | A [zone](zones.md) combined with [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones) | Frigate keeps detecting and tracking activity everywhere in the frame, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. | -| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. | -| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. | -| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. | +| What you're trying to do | Recommended tool | How it works | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Only get alerts/detections for activity in the areas you care about, ignoring activity elsewhere (e.g., alert when someone enters your yard, but not when they walk past on the sidewalk) | A [zone](zones.md) combined with [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones) | Frigate keeps detecting and tracking activity everywhere in the frame, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. | +| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. | +| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. | +| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. | ## Using the mask creator -Navigate to and select a camera. Use the mask editor to draw motion masks and object filter masks directly on the camera feed. Each mask can be given a friendly name and toggled on or off. + diff --git a/docs/docs/configuration/motion_detection.md b/docs/docs/configuration/motion_detection.md index a7928954e2..eaf0e8c606 100644 --- a/docs/docs/configuration/motion_detection.md +++ b/docs/docs/configuration/motion_detection.md @@ -4,6 +4,7 @@ title: Motion Detection --- import ConfigTabs from "@site/src/components/ConfigTabs"; +import FrigateConfigMock from "@site/src/components/FrigateConfigMock"; import TabItem from "@theme/TabItem"; import NavPath from "@site/src/components/NavPath"; @@ -44,13 +45,13 @@ The threshold value dictates how much of a change in a pixels luminance is requi -Navigate to to set the threshold globally. - -To override for a specific camera, navigate to and select the camera, or use the to adjust it live. - -| Field | Description | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Motion threshold** | The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive. The value should be between 1 and 255. (default: 30) | + diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 23595ac6e8..dd8d5f493c 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -5,6 +5,7 @@ title: Object Detectors import CommunityBadge from '@site/src/components/CommunityBadge'; import ConfigTabs from '@site/src/components/ConfigTabs'; +import FrigateConfigMock from '@site/src/components/FrigateConfigMock'; import TabItem from '@theme/TabItem'; import NavPath from '@site/src/components/NavPath'; import ModelConfigDropdown from '@site/src/components/ModelConfigDropdown'; @@ -112,7 +113,17 @@ See [common Edge TPU troubleshooting steps](/troubleshooting/edgetpu) if the Edg -Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. + @@ -132,7 +143,24 @@ detectors: -Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `usb:0` and `usb:1` as the device for each. + @@ -157,7 +185,19 @@ _warning: may have [compatibility issues](https://github.com/blakeblackshear/fri -Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add**, then leave the device field empty. + @@ -177,7 +217,18 @@ detectors: -Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `pci`. + @@ -197,7 +248,24 @@ detectors: -Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `pci:0` and `pci:1` as the device for each. + @@ -220,7 +288,24 @@ detectors: -Navigate to and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors with different device types (e.g., `usb` and `pci`). + diff --git a/docs/docs/configuration/zones.md b/docs/docs/configuration/zones.md index 2a26453203..07f581df0b 100644 --- a/docs/docs/configuration/zones.md +++ b/docs/docs/configuration/zones.md @@ -5,7 +5,7 @@ title: Zones import ConfigTabs from "@site/src/components/ConfigTabs"; import TabItem from "@theme/TabItem"; -import NavPath from "@site/src/components/NavPath"; +import FrigateConfigMock from "@site/src/components/FrigateConfigMock"; Zones allow you to define a specific area of the frame and apply additional filters for object types so you can determine whether or not an object is within a particular area. Presence in a zone is evaluated based on the bottom center of the bounding box for the object. It does not matter how much of the bounding box overlaps with the zone. @@ -25,11 +25,32 @@ During testing, enable the Zones option for the [Debug view](/usage/live#the-sin -1. Navigate to and select the desired camera. -2. Under the **Zones** section, click the plus icon to add a new zone. -3. Click on the camera's latest image to create the points for the zone boundary. Click the first point again to close the polygon. -4. Configure zone options such as **Friendly name**, **Objects**, **Loitering time**, and **Inertia** in the zone editor. -5. Press **Save** when finished. + @@ -57,11 +78,14 @@ To create an alert only when an object enters the `entire_yard` zone: -Navigate to . - -| Field | Description | -| ---------------------------------- | ----------------------------------------------------------------------------------------- | -| **Alerts config > Required zones** | Set to `entire_yard` so an object must enter that zone to be considered an alert; leave empty to allow alerts anywhere in the frame. | + @@ -87,12 +111,26 @@ You may also want to filter detections to only be created when an object enters -Navigate to . - -| Field | Description | -| -------------------------------------- | -------------------------------------------------------------------------------------------- | -| **Alerts config > Required zones** | Set to `inner_yard` so an object must enter that zone to be considered an alert; leave empty to allow alerts anywhere in the frame. | -| **Detections config > Required zones** | Set to `edge_yard` so an object must enter that zone to be considered a detection; leave empty to allow detections anywhere in the frame. | + @@ -126,8 +164,15 @@ To only save snapshots when an object enters a specific zone, for example an `en -1. Navigate to and select your camera. - - Set **Required zones** to `entire_yard` + @@ -154,11 +199,15 @@ Sometimes you want to limit a zone to specific object types to have more granula -1. Navigate to and select the desired camera. -2. Create a zone named `entire_yard` covering everywhere you want to track a person. - - Under **Objects**, add `person` -3. Create a second zone named `front_yard_street` covering just the street. - - Under **Objects**, add `car` + @@ -198,10 +247,15 @@ When using loitering zones, a review item will behave in the following way: -1. Navigate to and select the desired camera. -2. Edit or create the zone (e.g., `sidewalk`). - - Set **Loitering time** to the desired number of seconds (e.g., `4`) - - Under **Objects**, add the relevant object types (e.g., `person`) + @@ -227,9 +281,15 @@ Sometimes an objects bounding box may be slightly incorrect and the bottom cente -1. Navigate to and select the desired camera. -2. Edit or create the zone (e.g., `front_yard`). - - Set **Inertia** to the desired number of consecutive frames (e.g., `3`) + @@ -253,9 +313,15 @@ There may also be cases where you expect an object to quickly enter and exit a z -1. Navigate to and select the desired camera. -2. Edit or create the zone (e.g., `driveway_entrance`). - - Set **Inertia** to `1` + @@ -289,11 +355,23 @@ Accurate real-world distance measurements are required to estimate speeds. These -1. Navigate to and select the desired camera. -2. Create or edit a zone with exactly 4 points aligned to the ground plane. -3. In the zone editor, enter the real-world **Distances** between each pair of consecutive points. - - For example, if the distance between the first and second points is 10 meters, between the second and third is 12 meters, etc. -4. Distances are measured in meters (metric) or feet (imperial), depending on the **Unit system** setting. + @@ -317,11 +395,14 @@ The `distance` values are measured in meters (metric) or feet (imperial), depend -Navigate to . - -| Field | Description | -| --------------- | -------------------------------------------------------------------- | -| **Unit system** | Set to `metric` (kilometers per hour) or `imperial` (miles per hour) | + @@ -356,10 +437,15 @@ Zones can be configured with a minimum speed requirement, meaning an object must -1. Navigate to and select the desired camera. -2. Edit or create the zone with distances configured. - - Set **Speed threshold** to the desired minimum speed (e.g., `20`) - - The unit is kph or mph, depending on the **Unit system** setting + diff --git a/docs/package-lock.json b/docs/package-lock.json index 2310274651..2ea5f28c1e 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -19,10 +19,12 @@ "docusaurus-plugin-openapi-docs": "^4.5.1", "docusaurus-theme-openapi-docs": "^4.5.1", "js-yaml": "^4.1.1", + "marked": "^16.4.2", "prism-react-renderer": "^2.4.1", "raw-loader": "^4.0.2", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-icons": "^5.7.0" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.7.0", @@ -18761,6 +18763,15 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-icons": { + "version": "5.7.0", + "resolved": "https://registry.npmmirror.com/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/docs/package.json b/docs/package.json index 1649df7e0c..39ed1a3ab0 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,9 +4,11 @@ "private": true, "scripts": { "build:config": "node scripts/build-config.mjs", + "build:mock": "node scripts/generate-mock-manifest.mjs", + "check:mock": "node scripts/generate-mock-manifest.mjs --check", "docusaurus": "docusaurus", - "start": "npm run build:config && npm run regen-docs && docusaurus start --host 0.0.0.0", - "build": "npm run build:config && npm run regen-docs && docusaurus build", + "start": "npm run build:config && npm run build:mock && npm run regen-docs && docusaurus start --host 0.0.0.0", + "build": "npm run build:config && npm run build:mock && npm run regen-docs && docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", @@ -33,7 +35,8 @@ "prism-react-renderer": "^2.4.1", "raw-loader": "^4.0.2", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-icons": "^5.7.0" }, "browserslist": { "production": [ diff --git a/docs/scripts/generate-mock-manifest.mjs b/docs/scripts/generate-mock-manifest.mjs new file mode 100644 index 0000000000..7209f9b382 --- /dev/null +++ b/docs/scripts/generate-mock-manifest.mjs @@ -0,0 +1,366 @@ +#!/usr/bin/env node + +/** Build the compact field catalog used by documentation config mocks. */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, "../.."); +const schemaPath = path.join( + repoRoot, + "web/e2e/fixtures/mock-data/config-schema.json", +); +const localeRoot = path.join(repoRoot, "web/public/locales/en/config"); +const sectionConfigRoot = path.join( + repoRoot, + "web/src/components/config-form/section-configs", +); +const settingsSourcePath = path.join(repoRoot, "web/src/pages/Settings.tsx"); +const settingsLocalePath = path.join( + repoRoot, + "web/public/locales/en/views/settings.json", +); +const outputPath = path.join( + repoRoot, + "docs/src/components/FrigateConfigMock/manifest.json", +); + +const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); +const translations = { + global: JSON.parse( + fs.readFileSync(path.join(localeRoot, "global.json"), "utf8"), + ), + camera: JSON.parse( + fs.readFileSync(path.join(localeRoot, "cameras.json"), "utf8"), + ), + groups: JSON.parse( + fs.readFileSync(path.join(localeRoot, "groups.json"), "utf8"), + ), +}; +const settingsTranslations = JSON.parse( + fs.readFileSync(settingsLocalePath, "utf8"), +); + +function resolveNode(node) { + if (!node || typeof node !== "object") return {}; + + if (node.$ref) { + const refName = node.$ref.split("/").at(-1); + return { + ...resolveNode(schema.$defs?.[refName]), + ...node, + $ref: undefined, + }; + } + + const variants = node.anyOf ?? node.oneOf; + if (Array.isArray(variants)) { + const concrete = variants.find((variant) => variant.type !== "null"); + return { + ...resolveNode(concrete), + ...node, + anyOf: undefined, + oneOf: undefined, + }; + } + + return node; +} + +function translationAt(level, section, fieldPath) { + let current = translations[level]?.[section]; + for (const segment of fieldPath) { + if (!current || typeof current !== "object") return {}; + current = current[segment]; + } + return current && typeof current === "object" ? current : {}; +} + +function inferWidget(node) { + if (Array.isArray(node.enum)) return "select"; + if (node.type === "boolean") return "switch"; + if ( + ["integer", "number"].includes(node.type) && + node.minimum !== undefined && + (node.maximum !== undefined || node.exclusiveMaximum !== undefined) + ) { + return "range"; + } + if (node.type === "integer" || node.type === "number") return "number"; + if (node.type === "array") return "tags"; + if (node.type === "object") return "object"; + return "text"; +} + +function extractArray(source, key) { + const match = source.match(new RegExp(`${key}\\s*:\\s*\\[([\\s\\S]*?)\\]`)); + return match + ? [...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1]) + : []; +} + +function extractObjectBlock(source, key) { + const match = new RegExp(`\\b${key}\\s*:\\s*\\{`).exec(source); + if (!match) return ""; + const start = source.indexOf("{", match.index); + let depth = 0; + let quote = null; + let escaped = false; + for (let index = start; index < source.length; index += 1) { + const character = source[index]; + if (quote) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = null; + continue; + } + if (['"', "'", "`"].includes(character)) { + quote = character; + continue; + } + if (character === "{") depth += 1; + if (character === "}") { + depth -= 1; + if (depth === 0) return source.slice(start + 1, index); + } + } + return ""; +} + +function extractGroups(source) { + const fieldGroups = {}; + const groupsBlock = extractObjectBlock(source, "fieldGroups"); + for (const match of groupsBlock.matchAll(/(\w+)\s*:\s*\[([\s\S]*?)\]/g)) { + fieldGroups[match[1]] = [...match[2].matchAll(/["']([^"']+)["']/g)].map( + (item) => item[1], + ); + } + return fieldGroups; +} + +function loadSectionHints(section, level) { + const configPath = path.join(sectionConfigRoot, `${section}.ts`); + if (!fs.existsSync(configPath)) return {}; + const source = fs.readFileSync(configPath, "utf8"); + const base = extractObjectBlock(source, "base"); + const override = extractObjectBlock(source, level); + const overrideHas = (key) => new RegExp(`\\b${key}\\s*:`).test(override); + return { + order: overrideHas("fieldOrder") + ? extractArray(override, "fieldOrder") + : extractArray(base, "fieldOrder"), + hidden: [ + ...extractArray(base, "hiddenFields"), + ...extractArray(override, "hiddenFields"), + ], + advanced: overrideHas("advancedFields") + ? extractArray(override, "advancedFields") + : extractArray(base, "advancedFields"), + groups: overrideHas("fieldGroups") + ? extractGroups(override) + : extractGroups(base), + docs: base.match(/sectionDocs\s*:\s*["']([^"']+)["']/)?.[1] ?? null, + }; +} + +function groupLabel(level, section, group) { + const domain = level === "camera" ? "cameras" : "global"; + return ( + translations.groups?.[section]?.[domain]?.[group] ?? + group.replaceAll("_", " ").replace(/^./, (value) => value.toUpperCase()) + ); +} + +function collectFields(level, section, sectionNode, hints) { + const fields = {}; + + function visit(rawNode, fieldPath = []) { + const node = resolveNode(rawNode); + const properties = node.properties; + if (properties && typeof properties === "object") { + for (const [name, child] of Object.entries(properties)) { + visit(child, [...fieldPath, name]); + } + return; + } + + if (fieldPath.length === 0) return; + const key = fieldPath.join("."); + const localized = translationAt(level, section, fieldPath); + fields[key] = { + label: localized.label ?? node.title ?? fieldPath.at(-1), + description: localized.description ?? node.description ?? "", + widget: inferWidget(node), + default: node.default ?? null, + enum: node.enum ?? null, + minimum: node.minimum ?? node.exclusiveMinimum ?? null, + maximum: node.maximum ?? node.exclusiveMaximum ?? null, + advanced: hints.advanced?.includes(key) ?? false, + }; + } + + visit(sectionNode); + return fields; +} + +function buildLevel(level) { + const rootProperties = + level === "camera" + ? resolveNode(schema.$defs.CameraConfig).properties + : schema.properties; + const result = {}; + + for (const [section, rawNode] of Object.entries(rootProperties ?? {})) { + const node = resolveNode(rawNode); + if (!node.properties) continue; + + const hints = loadSectionHints(section, level); + const hidden = new Set(hints.hidden ?? []); + const fields = collectFields(level, section, node, hints); + for (const key of hidden) delete fields[key]; + + const localized = translations[level]?.[section] ?? {}; + result[section] = { + label: localized.label ?? rawNode.title ?? node.title ?? section, + description: localized.description ?? rawNode.description ?? "", + order: hints.order ?? [], + groups: Object.entries(hints.groups ?? {}).map(([key, groupFields]) => ({ + key, + label: groupLabel(level, section, key), + fields: groupFields, + })), + docs: hints.docs ?? null, + fields, + }; + } + + return result; +} + +function parseSectionMapping(source, constantName, level) { + const match = source.match( + new RegExp(`const ${constantName}[^=]*=\\s*\\{([\\s\\S]*?)\\n\\};`), + ); + if (!match) return []; + return [...match[1].matchAll(/(\w+)\s*:\s*"([^"]+)"/g)].map( + ([, section, page]) => ({ section, page, level }), + ); +} + +function buildNavigation() { + const source = fs.readFileSync(settingsSourcePath, "utf8"); + const settingsBlock = source.match( + /const settingsGroups\s*=\s*\[([\s\S]*?)\n\];/, + )?.[1]; + if (!settingsBlock) return { groups: [], pages: {} }; + + const mappings = [ + ...parseSectionMapping(source, "GLOBAL_SECTION_MAPPING", "global"), + ...parseSectionMapping(source, "CAMERA_SECTION_MAPPING", "camera"), + ...parseSectionMapping(source, "ENRICHMENTS_SECTION_MAPPING", "global"), + ...parseSectionMapping(source, "SYSTEM_SECTION_MAPPING", "global"), + ]; + const pages = Object.fromEntries( + mappings.map((mapping) => [mapping.page, mapping]), + ); + const groupMatches = [...settingsBlock.matchAll(/\{\s*label:\s*"([^"]+)"/g)]; + const groups = groupMatches.map((match, index) => { + const start = match.index ?? 0; + const end = groupMatches[index + 1]?.index ?? settingsBlock.length; + const sourceSlice = settingsBlock.slice(start, end); + const itemKeys = [...sourceSlice.matchAll(/key:\s*"([^"]+)"/g)].map( + (item) => item[1], + ); + return { + key: match[1], + label: settingsTranslations.menu?.[match[1]] ?? match[1], + items: itemKeys.map((key) => ({ + key, + label: settingsTranslations.menu?.[key] ?? key, + ...(key === "masksAndZones" + ? { section: key, page: key, level: "camera" } + : {}), + ...(pages[key] ?? {}), + })), + }; + }); + + return { groups, pages }; +} + +function buildDetectorTypes() { + const detectorTranslations = translations.global?.detectors ?? {}; + const reserved = new Set([ + "label", + "description", + "type", + "model", + "model_path", + ]); + + return Object.fromEntries( + Object.entries(detectorTranslations) + .filter( + ([key, value]) => + !reserved.has(key) && + value && + typeof value === "object" && + typeof value.label === "string" && + typeof value.description === "string", + ) + .map(([type, value]) => [ + type, + { + label: value.label, + description: value.description, + fields: Object.fromEntries( + Object.entries(value) + .filter( + ([key, field]) => + !["label", "description"].includes(key) && + field && + typeof field === "object" && + typeof field.label === "string", + ) + .map(([key, field]) => [ + key, + { + label: field.label, + description: field.description ?? "", + }, + ]), + ), + }, + ]), + ); +} + +const manifest = { + generatedFrom: path.relative(repoRoot, schemaPath).replaceAll("\\", "/"), + detectorTypes: buildDetectorTypes(), + levels: { + global: buildLevel("global"), + camera: buildLevel("camera"), + }, + navigation: buildNavigation(), +}; + +const serialized = `${JSON.stringify(manifest, null, 2)}\n`; +if (process.argv.includes("--check")) { + const current = fs.existsSync(outputPath) + ? fs.readFileSync(outputPath, "utf8") + : ""; + if (current !== serialized) { + console.error( + `${path.relative(repoRoot, outputPath)} is stale. Run npm run build:mock.`, + ); + process.exit(1); + } + console.log(`Checked ${path.relative(repoRoot, outputPath)}`); +} else { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, serialized); + console.log(`Generated ${path.relative(repoRoot, outputPath)}`); +} diff --git a/docs/scripts/generate_ui_tabs.py b/docs/scripts/generate_ui_tabs.py index fa468922c3..5f2fd443f1 100644 --- a/docs/scripts/generate_ui_tabs.py +++ b/docs/scripts/generate_ui_tabs.py @@ -45,7 +45,7 @@ from lib.i18n_loader import load_i18n from lib.nav_map import ALL_CONFIG_SECTIONS from lib.schema_loader import load_schema from lib.section_config_parser import load_section_configs -from lib.ui_generator import generate_ui_content, wrap_with_config_tabs +from lib.ui_generator import generate_mock_content, generate_ui_content, wrap_with_config_tabs from lib.yaml_extractor import ( extract_config_tabs_blocks, extract_yaml_blocks, @@ -60,6 +60,7 @@ def process_file( inject: bool = False, verbose: bool = False, outpath: Path | None = None, + mock: bool = False, ) -> dict: """Process a single markdown file for initial injection of bare YAML blocks. @@ -114,7 +115,8 @@ def process_file( continue # Generate UI content - ui_content = generate_ui_content( + generator = generate_mock_content if mock else generate_ui_content + ui_content = generator( block, schema, i18n, section_configs ) @@ -188,6 +190,7 @@ def regenerate_file( dry_run: bool = False, verbose: bool = False, outpath: Path | None = None, + mock: bool = False, ) -> dict: """Regenerate UI tabs in existing ConfigTabs blocks. @@ -233,7 +236,8 @@ def regenerate_file( continue # Generate fresh UI content - new_ui = generate_ui_content( + generator = generate_mock_content if mock else generate_ui_content + new_ui = generator( yaml_block, schema, i18n, section_configs ) @@ -302,6 +306,7 @@ def check_file( i18n: dict, section_configs: dict, verbose: bool = False, + mock: bool = False, ) -> dict: """Check for drift between existing UI tabs and what would be generated. @@ -333,7 +338,8 @@ def check_file( stats["skipped"] += 1 continue - new_ui = generate_ui_content( + generator = generate_mock_content if mock else generate_ui_content + new_ui = generator( yaml_block, schema, i18n, section_configs ) @@ -406,6 +412,10 @@ def _ensure_imports(content: str) -> str: needed_imports.append( 'import NavPath from "@site/src/components/NavPath";' ) + if " str | None: + """Generate a focused Frigate config mock for a YAML block.""" + if block.section_key is None: + return None + + if block.is_camera_level: + cameras = block.parsed.get("cameras", {}) + camera_name = block.camera_name or next(iter(cameras), None) + if not camera_name or not isinstance(cameras.get(camera_name), dict): + return None + config = cameras[camera_name] + level = "camera" + else: + config = block.parsed + level = detect_level(block.section_key) + if level not in ("global", "camera"): + level = "global" + + steps: list[dict[str, object]] = [] + for section, section_data in config.items(): + if section not in ALL_CONFIG_SECTIONS or not isinstance( + section_data, dict + ): + continue + + hidden = get_hidden_fields(section_configs, section, level) + values: dict[str, object] = {} + for path, value in get_leaf_paths(section_data): + path_parts = list(path) + if not _is_hidden(path_parts[-1], path_parts, hidden): + values[".".join(path_parts)] = value + + if values: + steps.append( + { + "section": section, + "level": level, + "fields": list(values), + "values": values, + "focus": next(iter(values)), + } + ) + + if not steps: + return None + + if len(steps) == 1: + step = steps[0] + return "\n".join( + [ + "", + ] + ) + + return "\n".join( + [ + "", + ] + ) + + def _format_value( value: object, field_schema: dict[str, Any] | None, diff --git a/docs/scripts/tests/test_ui_generator.py b/docs/scripts/tests/test_ui_generator.py new file mode 100644 index 0000000000..b2360842ae --- /dev/null +++ b/docs/scripts/tests/test_ui_generator.py @@ -0,0 +1,99 @@ +"""Tests for focused Frigate configuration mock generation.""" + +import sys +import types +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +try: + import yaml # noqa: F401 +except ModuleNotFoundError: + yaml_stub = types.ModuleType("yaml") + yaml_stub.YAMLError = ValueError + yaml_stub.safe_load = lambda _value: {} + sys.modules["yaml"] = yaml_stub + +from lib.ui_generator import generate_mock_content +from lib.yaml_extractor import YamlBlock + + +def make_block(parsed: dict, section: str, camera: bool = False) -> YamlBlock: + """Create a parsed YAML block for generator tests.""" + return YamlBlock( + raw="", + parsed=parsed, + line_start=1, + line_end=1, + highlight=None, + has_comments=False, + inside_config_tabs=False, + section_key=section, + is_camera_level=camera, + camera_name="front_door" if camera else None, + config_keys=list(parsed), + ) + + +class TestGenerateMockContent(unittest.TestCase): + def test_generates_focused_global_section(self): + content = generate_mock_content( + make_block({"motion": {"threshold": 30}}, "motion"), + {}, + {}, + {}, + ) + + self.assertIn('section="motion"', content) + self.assertIn('level="global"', content) + self.assertIn('fields={["threshold"]}', content) + self.assertIn('values={{"threshold": 30}}', content) + self.assertIn('focus="threshold"', content) + + def test_unwraps_camera_and_omits_hidden_fields(self): + content = generate_mock_content( + make_block( + { + "cameras": { + "front_door": { + "motion": { + "threshold": 20, + "raw_mask": "ignored", + } + } + } + }, + "motion", + camera=True, + ), + {}, + {}, + {"motion": {"hiddenFields": ["raw_mask"]}}, + ) + + self.assertIn('level="camera"', content) + self.assertIn('fields={["threshold"]}', content) + self.assertNotIn("raw_mask", content) + + def test_generates_steps_for_multiple_sections(self): + content = generate_mock_content( + make_block( + { + "record": {"enabled": True}, + "snapshots": {"enabled": True}, + }, + "record", + ), + {}, + {}, + {}, + ) + + self.assertIn("steps={", content) + self.assertIn('"section": "record"', content) + self.assertIn('"section": "snapshots"', content) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/src/components/FrigateConfigMock/index.jsx b/docs/src/components/FrigateConfigMock/index.jsx new file mode 100644 index 0000000000..f0be121a5f --- /dev/null +++ b/docs/src/components/FrigateConfigMock/index.jsx @@ -0,0 +1,2314 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import Translate, { translate } from "@docusaurus/Translate"; +import { FaCompactDisc, FaVideo } from "react-icons/fa"; +import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa"; +import { BsPersonBoundingBox } from "react-icons/bs"; +import { IoSearch } from "react-icons/io5"; +import { MdCategory, MdVideoLibrary } from "react-icons/md"; +import { TbFaceId } from "react-icons/tb"; +import { + LuActivity, + LuChevronDown, + LuChevronLeft, + LuChevronRight, + LuExternalLink, + LuGithub, + LuInfo, + LuLanguages, + LuLifeBuoy, + LuList, + LuPause, + LuPlay, + LuPlus, + LuRotateCw, + LuSettings, + LuSquarePen, + LuSunMoon, + LuTrash2, +} from "react-icons/lu"; + +import manifest from "./manifest.json"; +import styles from "./styles.module.css"; + +const formatValue = (value) => { + if (value === null || value === undefined || value === "") { + return translate({ id: "configMock.notSet", message: "Not set" }); + } + if (Array.isArray(value)) return value.join(", "); + return String(value); +}; + +function MockControl({ field, value }) { + const effectiveValue = value ?? field.default; + + if (field.widget === "switch") { + return ( +