Compare commits

...
15 Commits
Author SHA1 Message Date
dependabot[bot]andGitHub 4385435b62 Bump zod from 3.23.8 to 4.6.2 in /web
Bumps [zod](https://github.com/colinhacks/zod) from 3.23.8 to 4.6.2.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.23.8...v4.6.2)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.6.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-15 17:41:51 +00:00
Josh HawkinsandGitHub 0c52a3175d update radix, react, konva and other web dependencies (#24354)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
konva 10.5 removed the private `Node._lastPos`, so `PolygonCanvas` now reads the dragged point from `getAbsolutePosition()`, which returns the position konva just applied. monaco-yaml 5.5 takes formatter options instead of a boolean for `format`. The radix packages move together so every shared primitive stays a single copy under the `react-slot` and `compose-refs` overrides.
2026-09-15 12:39:31 -05:00
Josh HawkinsandGitHub 4c648f8147 bump copy-to-clipboard to 4.0.2 (#24353) 2026-09-15 12:03:20 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e0d4337a25 Update memray requirement from ==1.15.* to ==1.20.* in /docker/main (#24343)
Updates the requirements on [memray](https://github.com/bloomberg/memray) to permit the latest version.
- [Release notes](https://github.com/bloomberg/memray/releases)
- [Changelog](https://github.com/bloomberg/memray/blob/main/NEWS.rst)
- [Commits](https://github.com/bloomberg/memray/compare/v1.15.0...v1.20.0)

---
updated-dependencies:
- dependency-name: memray
  dependency-version: 1.20.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:16:45 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5e689f2d85 Update uvicorn requirement from ==0.46.* to ==0.52.* in /docker/main (#24341)
Updates the requirements on [uvicorn](https://github.com/Kludex/uvicorn) to permit the latest version.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.46.0...0.52.4)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.52.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:16:40 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
57a2765d00 Update netaddr requirement from ==0.8.* to ==1.3.* in /docker/main (#24346)
Updates the requirements on [netaddr](https://github.com/netaddr/netaddr) to permit the latest version.
- [Release notes](https://github.com/netaddr/netaddr/releases)
- [Changelog](https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/netaddr/netaddr/compare/0.8.0...1.3.0)

---
updated-dependencies:
- dependency-name: netaddr
  dependency-version: 1.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:16:33 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dd77bae4f7 Bump @radix-ui/react-progress from 1.1.8 to 1.1.16 in /web (#24342)
Bumps [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) from 1.1.8 to 1.1.16.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:16:16 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8a98d7c9b1 Bump @radix-ui/react-radio-group from 1.3.8 to 1.4.7 in /web (#24339)
Bumps [@radix-ui/react-radio-group](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radio-group) from 1.3.8 to 1.4.7.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radio-group/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radio-group)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-radio-group"
  dependency-version: 1.4.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:00:33 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8a8da663c0 Bump lucide-react from 0.577.0 to 1.45.0 in /web (#24337)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.577.0 to 1.45.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.45.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:00:26 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
eddc9fccd1 Bump autoprefixer from 10.4.20 to 10.5.6 in /web (#24340)
Bumps [autoprefixer](https://github.com/postcss/autoprefixer) from 10.4.20 to 10.5.6.
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.20...10.5.6)

---
updated-dependencies:
- dependency-name: autoprefixer
  dependency-version: 10.5.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:00:19 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
84f981a77c Bump @radix-ui/react-toggle from 1.1.10 to 1.1.18 in /web (#24344)
Bumps [@radix-ui/react-toggle](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toggle) from 1.1.10 to 1.1.18.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toggle/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toggle)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-toggle"
  dependency-version: 1.1.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:00:12 -05:00
Josh HawkinsandGitHub 4100383738 Miscellaneous fixes (#24352)
* fix recordings unavailable endpoint when no params are provided

we already import the datetime class directly, so those attribute lookups raised AttributeError and the request returned 500

* reject JWTs whose role is no longer in the config

`/auth` trusted the role inside the JWT and re-signed it on refresh without checking the config, so a user whose restricted role was deleted kept a session carrying a role that isn't in `auth.roles`. The media, clip, recording, export and go2rtc checks treat a missing role the same as a role with no camera list, so that session could open every camera. `/auth` now returns 401 for a token whose role isn't configured, which sends the user back through login, and login already falls back to `viewer` for a role that's gone.

* delete the deleted camera group's layout, not the open one's

Deleting a camera group removed the layout of the group being viewed, because the dialog's delete was bound to `${activeGroup}-draggable-layout`. It also cleared the saved group, and both ran before the `config/set` request whether or not it succeeded, so deleting one group while viewing another lost the open group's layout and left the deleted group's layout behind. The deleted group's own layout is now removed after a successful save, and the saved group is only cleared when it was the open group.

* don't block API when querying PTZ info

camera_ptz_info is async but waited on the ONVIF controller's future with future.result(), blocking the API event loop for as long as a slow or unreachable ONVIF camera took to answer (including reconnect attempts), so every other async request stalled with it. Await the future with asyncio.wrap_future instead. The coroutine runs on the controller's own loop and thread, so this cannot deadlock.

* for custom exports, only allow admin users to add to existing cases

follows the existing convention where attaching an export to an existing case is admin-only on `POST /export/{camera}/...` and `POST /exports/batch`

* drop pending edits for a camera or profile that no longer exists

* match cached preview frames to their camera exactly

https://github.com/blakeblackshear/frigate/pull/22594 added a trailing `-` to the `preview_{camera}` prefix so `camera` stopped matching `camera2`'s frames, but camera names can contain `-`, so `front` still matched `front-door`'s. After a restart, `front`'s preview recorder deleted this hour's frames of a matching camera that sorted before it and added the timestamps of one that sorted after it, so ffmpeg was asked for files that don't exist and that hour's preview was lost. The offline fallback for `latest.jpg` could also return `front-door`'s frame for `front`, even to a user without access to `front-door`, and a short export could take its fallback thumbnail from the other camera. These now compare the full camera name taken from the file name.
2026-09-15 07:48:30 -05:00
Josh HawkinsandGitHub fa30a7e1ae Replace react-logviewer with virtua (#24334)
* bump react-logviewer to 6.5.5

* use virtua for UI logs
2026-09-15 05:44:04 -06:00
Josh HawkinsandGitHub eecc43ccef bump rjsf to 6.10.0 and add e2e test (#24332)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-09-14 13:36:27 -05:00
Nicolas MowenandGitHub 7821ecbb43 Migrate Hailo detector key and support hailo device (#24327)
* Migrate Hailo detector key and support hailo device

* Fix missing check
2026-09-14 08:23:36 -06:00
45 changed files with 1605 additions and 3186 deletions
+3 -3
View File
@@ -5,7 +5,7 @@ aiohttp == 3.12.*
starlette == 0.47.*
starlette-context == 0.5.*
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
uvicorn == 0.46.*
uvicorn == 0.52.*
slowapi == 0.1.*
joserfc == 1.6.*
cryptography == 46.0.*
@@ -61,7 +61,7 @@ rapidfuzz==3.12.*
argcomplete==2.0.*
contextlib2==0.6.*
future==0.18.*
netaddr==0.8.*
netaddr==1.3.*
netifaces==0.10.*
prometheus-client == 0.26.*
# TFLite
@@ -73,4 +73,4 @@ faster-whisper==1.2.*
librosa==0.11.*
soundfile==0.13.*
# Memory profiling
memray == 1.15.*
memray == 1.20.*
+5 -5
View File
@@ -36,13 +36,13 @@ edgeTPU:
height: 320 # <--- should match the imgsize of the model, typically 320
path: /config/model_cache/yolov9-s-relu6-best_320_int8_edgetpu.tflite
labelmap_path: /config/labels-coco17.txt
hailo8l:
title: Hailo-8/Hailo-8L
hailo:
title: Hailo
models:
- key: yolo
label: YOLO
recommended: true
download: If no custom model path or URL is provided, the Hailo detector automatically downloads the default model (YOLOv6n) from the Hailo Model Zoo on first startup based on the detected hardware. Once cached under `/config/model_cache/hailo`, the model works fully offline.
download: If no custom model path or URL is provided, the Hailo detector automatically downloads the default model (YOLOv6n) from the Hailo Model Zoo on first startup, choosing the build that matches the attached device. Once cached under `/config/model_cache`, the model works fully offline.
ui: |-
Navigate to **Settings > System > Detection models** and select **Hailo** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure the model settings:
@@ -60,7 +60,7 @@ hailo8l:
yaml: |-
models:
- devices:
- hailo8l:PCIe
- hailo:PCIe
width: 320
height: 320
input_tensor: nhwc
@@ -101,7 +101,7 @@ hailo8l:
yaml: |-
models:
- devices:
- hailo8l:PCIe
- hailo:PCIe
width: 300
height: 300
input_tensor: nhwc
+5 -5
View File
@@ -22,7 +22,7 @@ Frigate supports multiple different detectors that work on different types of ha
**Most Hardware**
- [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB, Mini PCIe, and m.2 formats allowing for a wide range of compatibility with devices.
- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
- [Hailo](#hailo): The Hailo-8, Hailo-8L and Hailo-8R AI Acceleration modules are available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
- <CommunityBadge /> [MemryX](#memryx-mx3): The MX3 Acceleration module is available in m.2 format, offering broad compatibility across various platforms.
**AMD**
@@ -285,9 +285,9 @@ models:
---
## Hailo-8
## Hailo
This detector is available for use with both Hailo-8 and Hailo-8L AI Acceleration Modules. The integration automatically detects your hardware architecture via the Hailo CLI and selects the appropriate default model if no custom model is specified.
This detector is available for use with the Hailo-8, Hailo-8L and Hailo-8R AI Acceleration Modules. The integration identifies which of them is attached and selects the matching default model if no custom model is specified.
See the [installation docs](../frigate/installation.md#hailo-8) for information on configuring the Hailo hardware.
@@ -308,11 +308,11 @@ The HailoRT runtime is not part of the Frigate image. It is downloaded and insta
When configuring the Hailo detector, you have two options to specify the model: a local **path** or a **URL**.
If both are provided, the detector will first check for the model at the given local path. If the file is not found, it will download the model from the specified URL. The model file is cached under `/config/model_cache/hailo`.
<ModelConfigDropdown detectorTitle="Hailo-8/Hailo-8L" models={objectDetectorsModels.hailo8l.models} />
<ModelConfigDropdown detectorTitle="Hailo" models={objectDetectorsModels.hailo.models} />
For additional ready-to-use models, please visit: https://github.com/hailo-ai/hailo_model_zoo
Hailo8 supports all models in the Hailo Model Zoo that include HailoRT post-processing. You're welcome to choose any of these pre-configured models for your implementation.
Hailo supports all models in the Hailo Model Zoo that include HailoRT post-processing. You're welcome to choose any of these pre-configured models for your implementation.
> **Note:**
> The config.path parameter can accept either a local file path or a URL ending with .hef. When provided, the detector will first check if the path is a local file path. If the file exists locally, it will use it directly. If the file is not found locally or if a URL was provided, it will attempt to download the model from the specified URL.
+5 -4
View File
@@ -54,7 +54,7 @@ Frigate supports multiple different detectors that work on different types of ha
**Most Hardware**
- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices offering a wide range of compatibility with devices.
- [Hailo](#hailo-8): The Hailo-8, Hailo-8L and Hailo-8R AI Acceleration modules are available in m.2 format with a HAT for RPi devices offering a wide range of compatibility with devices.
- [Supports many model architectures](../../configuration/object_detectors#configuration-hailo)
- Runs best with tiny or small size models
@@ -111,12 +111,13 @@ Frigate supports multiple different detectors that work on different types of ha
### Hailo-8
Frigate supports both the Hailo-8 and Hailo-8L AI Acceleration Modules on compatible hardware platforms, including the Raspberry Pi 5 with the PCIe hat from the AI kit. The Hailo detector integration in Frigate automatically identifies your hardware type and selects the appropriate default model when a custom model isn’t provided.
Frigate supports the Hailo-8, Hailo-8L and Hailo-8R AI Acceleration Modules on compatible hardware platforms, including the Raspberry Pi 5 with the PCIe hat from the AI kit. The Hailo detector integration in Frigate identifies which of them is attached and selects the matching default model when a custom model isn’t provided.
**Default Model Configuration:**
- **Hailo-8L:** Default model is **YOLOv6n**.
- **Hailo-8:** Default model is **YOLOv6n**.
- **Hailo-8L:** Default model is **YOLOv6n**, compiled for the Hailo-8L.
- **Hailo-8:** Default model is **YOLOv6n**, compiled for the Hailo-8.
- **Hailo-8R:** Default model is the **Hailo-8** build of **YOLOv6n**, since the Hailo Model Zoo publishes no Hailo-8R build.
In real-world deployments, even with multiple cameras running concurrently, Frigate has demonstrated consistent performance. Testing on x86 platforms, with dual PCIe lanes, yields further improvements in FPS, throughput, and latency compared to the Raspberry Pi setup.
+2 -2
View File
@@ -122,7 +122,7 @@ Additionally, the USB Coral draws a considerable amount of power. If using any o
### Hailo-8
The Hailo-8 and Hailo-8L AI accelerators are available in both M.2 and HAT form factors for the Raspberry Pi. The M.2 version typically connects to a carrier board for PCIe, which then interfaces with the Raspberry Pi 5 as part of the AI Kit. The HAT version can be mounted directly onto compatible Raspberry Pi models. Both form factors have been successfully tested on x86 platforms as well, making them versatile options for various computing environments.
The Hailo-8, Hailo-8L and Hailo-8R AI accelerators are available in both M.2 and HAT form factors for the Raspberry Pi. The M.2 version typically connects to a carrier board for PCIe, which then interfaces with the Raspberry Pi 5 as part of the AI Kit. The HAT version can be mounted directly onto compatible Raspberry Pi models. Both form factors have been successfully tested on x86 platforms as well, making them versatile options for various computing environments.
The HailoRT runtime is not part of the Frigate image; Frigate downloads and installs it at first start once a Hailo detector is configured. Containers without internet access can provide the files themselves, see [Detector runtimes](/frigate/network_requirements#detector-runtimes).
@@ -300,7 +300,7 @@ If you are using `docker run`, add this option to your command `--device /dev/ha
#### Configuration
Finally, configure [hardware object detection](/configuration/object_detectors#hailo-8) to complete the setup.
Finally, configure [hardware object detection](/configuration/object_detectors#hailo) to complete the setup.
### MemryX MX3
+7 -7
View File
@@ -47,7 +47,7 @@ If you are using one of the following hardware detectors and have not provided y
| Detector | Model Downloaded | Source |
| ------------------------------------------------------------------ | -------------------- | ------------------------ |
| [Rockchip RKNN](/configuration/object_detectors#rockchip-platform) | RKNN detection model | GitHub |
| [Hailo 8 / 8L](/configuration/object_detectors#hailo-8) | YOLOv6n (.hef) | Hailo Model Zoo (AWS S3) |
| [Hailo 8 / 8L / 8R](/configuration/object_detectors#hailo) | YOLOv6n (.hef) | Hailo Model Zoo (AWS S3) |
| [AXERA AXEngine](/configuration/object_detectors) | Detection model | HuggingFace |
:::note
@@ -60,16 +60,16 @@ The default CPU, EdgeTPU, and OpenVINO object detection models are bundled into
The SDKs for a few hardware detectors are not shipped in the Frigate image. They are downloaded the first time that detector is configured, verified against checksums pinned in the Frigate release, and installed into the Frigate user's home directory (`/config/.local` by default). Once installed they are not downloaded again until a Frigate release pins a new version.
| Detector | Version | Files | Source |
| -------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| [Hailo 8 / 8L](/configuration/object_detectors#hailo-8) | 4.21.0 | `hailort-debian12-amd64.tar.gz` and `hailort-4.21.0-cp311-cp311-linux_x86_64.whl` on x86, `hailort-debian12-arm64.tar.gz` and `hailort-4.21.0-cp311-cp311-linux_aarch64.whl` on arm64 | [GitHub release](https://github.com/frigate-nvr/hailort/releases/tag/v4.21.0) |
| [MemryX MX3](/configuration/object_detectors#memryx-mx3) | 2.1.0 | `mx_accl_frigate-2.1.0.zip` (the release source archive, renamed) | [GitHub archive](https://github.com/memryx/mx_accl_frigate/archive/refs/tags/v2.1.0.zip) |
| [AXERA AXEngine](/configuration/object_detectors#axera) | 0.1.3 | `axengine-0.1.3-py3-none-any.whl` | [GitHub release](https://github.com/AXERA-TECH/pyaxengine/releases/tag/0.1.3-frigate) |
| Detector | Version | Files | Source |
| ---------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| [Hailo 8 / 8L / 8R](/configuration/object_detectors#hailo) | 4.21.0 | `hailort-debian12-amd64.tar.gz` and `hailort-4.21.0-cp311-cp311-linux_x86_64.whl` on x86, `hailort-debian12-arm64.tar.gz` and `hailort-4.21.0-cp311-cp311-linux_aarch64.whl` on arm64 | [GitHub release](https://github.com/frigate-nvr/hailort/releases/tag/v4.21.0) |
| [MemryX MX3](/configuration/object_detectors#memryx-mx3) | 2.1.0 | `mx_accl_frigate-2.1.0.zip` (the release source archive, renamed) | [GitHub archive](https://github.com/memryx/mx_accl_frigate/archive/refs/tags/v2.1.0.zip) |
| [AXERA AXEngine](/configuration/object_detectors#axera) | 0.1.3 | `axengine-0.1.3-py3-none-any.whl` | [GitHub release](https://github.com/AXERA-TECH/pyaxengine/releases/tag/0.1.3-frigate) |
If the container cannot reach GitHub, provide the files yourself:
1. Download the files for your architecture on a machine with internet access.
2. Place them, with exactly the file names listed above, in `/config/model_cache/runtimes/<detector>/`, where `<detector>` is the detector `type` from your config (`hailo8l`, `memryx`, or `axengine`).
2. Place them, with exactly the file names listed above, in `/config/model_cache/runtimes/<detector>/`, where `<detector>` is the detector named in your config's `devices` (`hailo`, `memryx`, or `axengine`).
3. Start Frigate. Files whose checksum matches are installed without any download; a file with the wrong checksum is discarded and downloaded again, so a failed startup log names the file to replace.
The `GITHUB_ENDPOINT` mirror variable below applies to these downloads as well.
+2 -2
View File
@@ -41,7 +41,7 @@ Rockchip models are automatically converted as of 0.17. For 0.16, YOLOv9 onnx mo
## Supported detector types
Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), ONNX (`onnx`), Hailo (`hailo8l`), and Rockchip (`rknn`) detectors.
Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), ONNX (`onnx`), Hailo (`hailo`), and Rockchip (`rknn`) detectors.
| Hardware | Recommended Detector Type | Recommended Model Type |
| -------------------------------------------------------------------------------- | ------------------------- | ---------------------- |
@@ -50,7 +50,7 @@ Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVi
| [Intel](/configuration/object_detectors.md#openvino-detector) | `openvino` | `yolov9` |
| [NVidia GPU](/configuration/object_detectors#onnx) | `onnx` | `yolov9` |
| [AMD ROCm GPU](/configuration/object_detectors#amdrocm-gpu-detector) | `onnx` | `yolov9` |
| [Hailo8/Hailo8L/Hailo8R](/configuration/object_detectors#hailo-8) | `hailo8l` | `yolov9` |
| [Hailo8/Hailo8L/Hailo8R](/configuration/object_detectors#hailo) | `hailo` | `yolov9` |
| [Rockchip NPU](/configuration/object_detectors#rockchip-platform) | `rknn` | `yolov9` |
## Improving your model
+2 -9
View File
@@ -2,7 +2,6 @@
import asyncio
import copy
import json
import logging
import os
import platform
@@ -11,7 +10,6 @@ import urllib
from datetime import datetime, timedelta
from functools import reduce
from io import StringIO
from pathlib import Path as FilePath
from typing import Any
import aiofiles
@@ -56,6 +54,7 @@ from frigate.jobs.media_sync import (
start_media_sync_job,
)
from frigate.models import Event, Timeline
from frigate.plus import load_plus_model_info
from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.types import JobStatusTypesEnum
from frigate.util.builtin import (
@@ -401,13 +400,7 @@ def config(request: Request):
model_dict["plus"] = None
if model.path:
model_json_path = FilePath(model.path).with_suffix(".json")
try:
with open(model_json_path) as f:
model_dict["plus"] = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
pass
model_dict["plus"] = load_plus_model_info(os.path.basename(model.path))
return JSONResponse(content=config)
+7
View File
@@ -786,6 +786,13 @@ def auth(request: Request):
user = token.claims.get("sub")
role = token.claims.get("role")
# the token keeps the role it was issued with, so a role removed from
# the config since then must send the user back through login
if role not in auth_config.roles:
logger.debug("jwt role %s is not in the config", role)
return fail_response
current_time = int(time.time())
# if the jwt is expired
+14 -4
View File
@@ -1038,6 +1038,10 @@ def export_recording_custom(
if camera_validation_error is not None:
return camera_validation_error
# Validate user-provided ffmpeg args to prevent injection and add to cases.
# Admin users are trusted and skip validation.
is_admin = request.headers.get("remote-role", "") == "admin"
playback_source = body.source
friendly_name = body.name
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
@@ -1048,6 +1052,16 @@ def export_recording_custom(
cpu_fallback = body.cpu_fallback
export_case_id = body.export_case_id
if export_case_id is not None and not is_admin:
return JSONResponse(
content={
"success": False,
"message": "Only admins can attach exports to an existing case.",
},
status_code=403,
)
case_validation_error = _validate_export_case(export_case_id)
if case_validation_error is not None:
return case_validation_error
@@ -1064,10 +1078,6 @@ def export_recording_custom(
status_code=400,
)
# Validate user-provided ffmpeg args to prevent injection.
# Admin users are trusted and skip validation.
is_admin = request.headers.get("remote-role", "") == "admin"
if not is_admin:
for args_label, args_value in [
("input", ffmpeg_input_args),
+1 -1
View File
@@ -189,7 +189,7 @@ async def camera_ptz_info(request: Request, camera_name: str):
future = asyncio.run_coroutine_threadsafe(
request.app.onvif.get_camera_info(camera_name), request.app.onvif.loop
)
result = future.result()
result = await asyncio.wrap_future(future)
return JSONResponse(content=result)
else:
return JSONResponse(
+2 -5
View File
@@ -412,11 +412,8 @@ async def no_recordings(
if not camera_list:
return JSONResponse(content=[])
before = params.before or datetime.datetime.now().timestamp()
after = (
params.after
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
)
before = params.before or datetime.now().timestamp()
after = params.after or (datetime.now() - timedelta(hours=1)).timestamp()
scale = params.scale
recordings: list[tuple[float, float]] = []
+8 -7
View File
@@ -3,14 +3,14 @@ import json
import logging
import os
from enum import Enum
from typing import Any, ClassVar
from typing import ClassVar
import requests
from pydantic import BaseModel, ConfigDict, Field
from pydantic.fields import PrivateAttr
from frigate.const import DEFAULT_ATTRIBUTE_LABEL_MAP, MODEL_CACHE_DIR
from frigate.plus import PlusApi
from frigate.plus import PlusApi, load_plus_model_info
from frigate.util.builtin import generate_color_palette, load_labels
logger = logging.getLogger(__name__)
@@ -190,12 +190,13 @@ class ModelConfig(BaseModel):
# download the model info if it doesn't exist
if not os.path.isfile(model_info_path):
model_info = plus_api.get_model_info(model_id)
with open(model_info_path, "w") as f:
json.dump(model_info, f)
else:
with open(model_info_path) as f:
model_info: dict[str, Any] = json.load(f)
json.dump(plus_api.get_model_info(model_id), f)
model_info = load_plus_model_info(model_id)
if model_info is None:
raise ValueError(f"Unable to read the model info for {model_id}")
if detector and detector not in model_info["supportedDetectors"]:
raise ValueError(f"Model does not support detector type of {detector}")
+2 -2
View File
@@ -259,8 +259,8 @@ def detect_hailo() -> DetectionHardware | None:
# the hailo runtime schedules across every attached device itself, so there
# is nothing to address individually
units = [HardwareUnit(device="hailo8l:PCIe", label=os.path.basename(nodes[0]))]
return _hardware("hailo8l", "hailo8l", "Hailo", units)
units = [HardwareUnit(device="hailo:PCIe", label=os.path.basename(nodes[0]))]
return _hardware("hailo", "hailo", "Hailo", units)
def detect_memryx() -> DetectionHardware | None:
@@ -53,7 +53,7 @@ def preprocess_tensor(image: np.ndarray, model_w: int, model_h: int) -> np.ndarr
# ----------------- Global Constants ----------------- #
DETECTOR_KEY = "hailo8l"
DETECTOR_KEY = "hailo"
ARCH = None
H8_DEFAULT_MODEL = "yolov6n.hef"
H8L_DEFAULT_MODEL = "yolov6n.hef"
@@ -469,10 +469,10 @@ class HailoDetector(DetectionApi):
# ----------------- HailoDetectorConfig Class ----------------- #
class HailoDetectorConfig(BaseDetectorConfig):
"""Hailo-8/Hailo-8L detector using HEF models and the HailoRT SDK for inference on Hailo hardware."""
"""Hailo detector using HEF models and the HailoRT SDK for inference on Hailo hardware."""
model_config = ConfigDict(
title="Hailo-8/Hailo-8L",
title="Hailo",
)
type: Literal[DETECTOR_KEY]
+8 -2
View File
@@ -66,6 +66,12 @@ def get_cache_image_name(camera: str, frame_time: float) -> str:
)
def is_camera_preview_frame(file_name: str, camera: str) -> bool:
"""Check whether a cached preview frame file belongs to the camera."""
# camera names may contain "-", so a prefix match would include "front-door"
return file_name.rsplit("-", 1)[0] == f"preview_{camera}"
def get_most_recent_preview_frame(
camera: str, before: float | None = None
) -> str | None:
@@ -79,7 +85,7 @@ def get_most_recent_preview_frame(
preview_files = [
f
for f in os.listdir(PREVIEW_CACHE_DIR)
if f.startswith(f"preview_{camera}-")
if is_camera_preview_frame(f, camera)
and f.endswith(f".{PREVIEW_FRAME_TYPE}")
]
@@ -276,7 +282,7 @@ class PreviewRecorder:
start_file = f"{file_start}{start_ts}.webp"
for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))):
if not file.startswith(file_start):
if not is_camera_preview_frame(file, self.camera_name):
continue
if file < start_file:
+46 -2
View File
@@ -11,11 +11,50 @@ import requests
from numpy import ndarray
from requests.models import Response
from frigate.const import PLUS_API_HOST, PLUS_ENV_VAR
from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
logger = logging.getLogger(__name__)
def add_hailo_alias(model_info: dict[str, Any]) -> dict[str, Any]:
"""Name the hailo detector by its current key as well as its old one.
Frigate+ reports every Hailo model as supporting hailo8l, which this
detector was called before it was renamed to cover every Hailo device.
The old key is kept so an older Frigate still matches the model.
Args:
model_info: A Frigate+ model's metadata, edited in place
Returns:
The same metadata
"""
supported = model_info.get("supportedDetectors")
if supported and "hailo8l" in supported and "hailo" not in supported:
supported.append("hailo")
return model_info
def load_plus_model_info(model_id: str) -> dict[str, Any] | None:
"""Read a Frigate+ model's cached info file.
Args:
model_id: The Frigate+ model id
Returns:
The model info, or None when it has not been cached or cannot be read
"""
try:
with open(os.path.join(MODEL_CACHE_DIR, f"{model_id}.json")) as f:
model_info: dict[str, Any] = json.load(f)
except (OSError, ValueError):
return None
return add_hailo_alias(model_info)
def get_jpg_bytes(image: ndarray, max_dim: int, quality: int) -> bytes:
if image.shape[1] >= image.shape[0]:
width = min(max_dim, image.shape[1])
@@ -241,4 +280,9 @@ class PlusApi:
if not r.ok:
raise Exception(r.text)
return r.json()
models = r.json()
for model in models.get("list") or []:
add_hailo_alias(model)
return models
+2 -1
View File
@@ -36,6 +36,7 @@ from frigate.ffmpeg_presets import (
parse_preset_hardware_acceleration_encode,
)
from frigate.models import Export, Previews, Recordings, ReviewSegment
from frigate.output.preview import is_camera_preview_frame
from frigate.util.ffmpeg import run_ffmpeg_with_progress
from frigate.util.ownership import chown_to_runtime
from frigate.util.recording_coverage import (
@@ -1098,7 +1099,7 @@ class RecordingExporter(threading.Thread):
fallback_preview = None
for file in sorted(os.listdir(preview_dir)):
if not file.startswith(file_start):
if not is_camera_preview_frame(file, self.camera):
continue
if file < start_file:
+1 -1
View File
@@ -467,7 +467,7 @@ def get_hardware_temperatures(detector_type: str) -> list[float | None]:
read_temperature(os.path.join(base, apex, "temp"))
for apex in sorted(os.listdir(base))
]
elif detector_type == "hailo8l":
elif detector_type == "hailo":
hailo_temps = get_hailo_temps()
return [hailo_temps[name] for name in sorted(hailo_temps.keys())]
@@ -0,0 +1,81 @@
"""Tests that /auth only accepts a JWT whose role is still in the config."""
import os
import time
from unittest.mock import MagicMock, Mock, patch
from frigate.api.auth import create_encoded_jwt
from frigate.api.fastapi_app import create_fastapi_app
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.const import JWT_SECRET_ENV_VAR
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
class TestAuthJwtRole(BaseTestHttp):
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
"networking": {"listen": {"internal": 5000, "external": 8971}},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
def _create_app(self):
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
return create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
enforce_default_admin=False,
)
def _auth(self, app, role: str):
token = create_encoded_jwt("bob", role, int(time.time()) + 3600, app.jwt_token)
with AuthTestClient(app) as client:
return client.get(
"/auth",
headers={
"x-server-port": "8971",
"authorization": f"Bearer {token}",
},
)
def test_configured_role_is_accepted(self):
resp = self._auth(self._create_app(), "garage")
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-user"], "bob")
self.assertEqual(resp.headers["remote-role"], "garage")
def test_role_removed_from_config_is_rejected(self):
resp = self._auth(self._create_app(), "removed_role")
self.assertEqual(resp.status_code, 401)
self.assertNotIn("remote-role", resp.headers)
+87
View File
@@ -12,6 +12,7 @@ from frigate.util.config import (
CURRENT_CONFIG_VERSION,
migrate_frigate_config,
migrate_models,
rename_hailo_detector,
)
@@ -164,6 +165,61 @@ class TestMigrateModels(unittest.TestCase):
self.assertEqual(migrated["mqtt"], {"host": "mqtt"})
class TestMigrateRenamedDetectors(unittest.TestCase):
def test_a_hailo_detector_is_renamed(self):
migrated = migrate_models(
{"detectors": {"hailo": {"type": "hailo8l", "device": "PCIe"}}}
)
self.assertEqual(migrated["models"][0]["devices"], ["hailo:PCIe"])
def test_a_hailo_detector_without_a_device(self):
migrated = migrate_models({"detectors": {"hailo": {"type": "hailo8l"}}})
self.assertEqual(migrated["models"][0]["devices"], ["hailo"])
def test_two_hailo_detectors_stay_separate(self):
# the shareable lookup has to resolve through the renamed key
migrated = migrate_models(
{
"detectors": {
"hailo1": {"type": "hailo8l", "device": "PCIe"},
"hailo2": {"type": "hailo8l", "device": "PCIe"},
}
}
)
self.assertEqual(migrated["models"][0]["devices"], ["hailo:PCIe", "hailo:PCIe"])
class TestRenameHailoDetector(unittest.TestCase):
def test_a_renamed_detector_is_updated(self):
migrated = rename_hailo_detector(
{"models": [{"devices": ["hailo8l:PCIe", "hailo8l"]}]}
)
self.assertEqual(migrated["models"][0]["devices"], ["hailo:PCIe", "hailo"])
def test_other_detectors_are_untouched(self):
migrated = rename_hailo_detector(
{"models": [{"devices": ["openvino:GPU", "cpu"]}]}
)
self.assertEqual(migrated["models"][0]["devices"], ["openvino:GPU", "cpu"])
def test_renaming_is_idempotent(self):
once = rename_hailo_detector({"models": [{"devices": ["hailo8l:PCIe"]}]})
twice = rename_hailo_detector(once)
self.assertEqual(twice["models"][0]["devices"], ["hailo:PCIe"])
def test_a_config_without_models_is_left_alone(self):
self.assertEqual(
rename_hailo_detector({"mqtt": {"enabled": False}}),
{"mqtt": {"enabled": False}},
)
class TestMigrateConfigFile(unittest.TestCase):
"""The full file migration, which is gated on shape as well as version."""
@@ -220,6 +276,37 @@ class TestMigrateConfigFile(unittest.TestCase):
os.path.exists(os.path.join(self.temp_dir.name, "backup_config.yaml"))
)
def test_a_legacy_hailo_detector_lands_on_the_hailo_key(self):
# the detectors key is folded into models after the version chain has
# run, so the rename has to happen there too
migrated = self._migrate(
"mqtt:\n"
" enabled: false\n"
"detectors:\n"
" hailo:\n"
" type: hailo8l\n"
" device: PCIe\n"
"cameras: {}\n"
"version: 0.18-0\n"
)
self.assertEqual(migrated["models"][0]["devices"], ["hailo:PCIe"])
self.assertNotIn("detectors", migrated)
def test_a_hailo8l_device_is_renamed_at_the_current_version(self):
migrated = self._migrate(
"mqtt:\n"
" enabled: false\n"
"models:\n"
" - scene: all\n"
" devices:\n"
" - hailo8l:PCIe\n"
"cameras: {}\n"
f"version: {CURRENT_CONFIG_VERSION}\n"
)
self.assertEqual(migrated["models"][0]["devices"], ["hailo:PCIe"])
if __name__ == "__main__":
unittest.main(verbosity=2)
+1 -1
View File
@@ -170,7 +170,7 @@ class TestAccelerators(HardwareProbeTestCase):
def test_hailo_is_found_by_its_device_node(self):
write(os.path.join(self.dev_root, "hailo0"))
self.assertEqual(self.probe()["hailo8l"].units[0].device, "hailo8l:PCIe")
self.assertEqual(self.probe()["hailo"].units[0].device, "hailo:PCIe")
def test_each_memryx_node_is_a_unit(self):
write(os.path.join(self.dev_root, "memx0"))
+2 -2
View File
@@ -200,7 +200,7 @@ class TestScanDetectors(HardwareStatsTestCase):
self.scan([DeviceSpec("openvino:CPU", "openvino", "CPU")]), set()
)
self.assertEqual(self.scan([DeviceSpec("cpu", "cpu", None)]), set())
self.assertEqual(self.scan([DeviceSpec("hailo8l", "hailo8l", None)]), set())
self.assertEqual(self.scan([DeviceSpec("hailo", "hailo", None)]), set())
def test_onnx_resolves_to_present_gpu(self):
spec = DeviceSpec("onnx", "onnx", None)
@@ -412,7 +412,7 @@ class TestHardwareTemperatures(unittest.TestCase):
return_value={"hailo8l-1": 52.0, "hailo8l-0": 51.0},
)
def test_hailo_sorted_by_name(self, temps):
self.assertEqual(get_hardware_temperatures("hailo8l"), [51.0, 52.0])
self.assertEqual(get_hardware_temperatures("hailo"), [51.0, 52.0])
def test_unsupported_type(self):
self.assertEqual(get_hardware_temperatures("rknn"), [])
+79
View File
@@ -0,0 +1,79 @@
"""Tests for Frigate+ model metadata."""
import json
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from frigate.plus import PlusApi, load_plus_model_info
class TestHailoAlias(unittest.TestCase):
"""Frigate+ reports Hailo models by the detector's pre-rename key.
Both the model list and a cached info file have to carry the current key,
or every Hailo model reads as unsupported.
"""
def _get_models(self, models: list[dict]) -> list[dict]:
api = PlusApi.__new__(PlusApi)
response = MagicMock(ok=True, json=lambda: {"list": models})
with patch.object(PlusApi, "_get", return_value=response):
return api.get_models()["list"]
def test_the_model_list_gains_the_current_key(self):
models = self._get_models([{"supportedDetectors": ["hailo8l"]}])
self.assertEqual(models[0]["supportedDetectors"], ["hailo8l", "hailo"])
def test_the_old_key_is_kept_for_older_versions(self):
models = self._get_models([{"supportedDetectors": ["hailo8l"]}])
self.assertIn("hailo8l", models[0]["supportedDetectors"])
def test_other_detectors_are_untouched(self):
models = self._get_models([{"supportedDetectors": ["openvino", "onnx"]}])
self.assertEqual(models[0]["supportedDetectors"], ["openvino", "onnx"])
def test_a_model_already_naming_both_is_unchanged(self):
models = self._get_models([{"supportedDetectors": ["hailo8l", "hailo"]}])
self.assertEqual(models[0]["supportedDetectors"], ["hailo8l", "hailo"])
def test_an_empty_list(self):
self.assertEqual(self._get_models([]), [])
class TestLoadPlusModelInfo(unittest.TestCase):
def setUp(self):
self.cache = tempfile.TemporaryDirectory()
self.addCleanup(self.cache.cleanup)
patcher = patch("frigate.plus.MODEL_CACHE_DIR", self.cache.name)
patcher.start()
self.addCleanup(patcher.stop)
def _write(self, model_id: str, content: str) -> None:
with open(os.path.join(self.cache.name, f"{model_id}.json"), "w") as f:
f.write(content)
def test_a_cached_hailo_model_gains_the_current_key(self):
self._write("abc", json.dumps({"supportedDetectors": ["hailo8l"]}))
self.assertEqual(
load_plus_model_info("abc")["supportedDetectors"], ["hailo8l", "hailo"]
)
def test_a_missing_file(self):
self.assertIsNone(load_plus_model_info("nope"))
def test_an_unreadable_file(self):
self._write("bad", "{not json")
self.assertIsNone(load_plus_model_info("bad"))
if __name__ == "__main__":
unittest.main(verbosity=2)
+15
View File
@@ -75,6 +75,21 @@ class TestPreviewLoader(unittest.TestCase):
self.assertIsNone(get_most_recent_preview_frame(camera))
def test_get_most_recent_preview_frame_hyphenated_camera(self):
for name in ("preview_front-2000.0", "preview_front-door-3000.0"):
with open(
os.path.join(PREVIEW_CACHE_DIR, f"{name}.{PREVIEW_FRAME_TYPE}"), "w"
) as f:
f.write("test")
expected_path = os.path.join(
PREVIEW_CACHE_DIR, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}"
)
self.assertEqual(get_most_recent_preview_frame("front"), expected_path)
self.assertEqual(
get_most_recent_preview_frame("front", before=5000.0), expected_path
)
def test_get_most_recent_preview_frame_no_directory(self):
shutil.rmtree(PREVIEW_CACHE_DIR)
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
+61 -2
View File
@@ -168,7 +168,20 @@ def migrate_frigate_config(config_file: str):
# version and still use the pre-models detectors and model keys
needs_models = "detectors" in config or "model" in config
if previous_version == CURRENT_CONFIG_VERSION and not needs_models:
# likewise, it may already be on the models list and still name the hailo
# detector by its old key
needs_detector_rename = any(
isinstance(device, str) and device.partition(":")[0] == "hailo8l"
for model in (config.get("models") or [])
if isinstance(model, dict)
for device in (model.get("devices") or [])
)
if (
previous_version == CURRENT_CONFIG_VERSION
and not needs_models
and not needs_detector_rename
):
logger.info("frigate config does not need migration...")
return
@@ -242,6 +255,12 @@ def migrate_frigate_config(config_file: str):
with open(config_file, "w") as f:
yaml.dump(new_config, f)
if needs_detector_rename:
logger.info("Migrating renamed frigate detectors...")
new_config = rename_hailo_detector(new_config)
with open(config_file, "w") as f:
yaml.dump(new_config, f)
logger.info("Finished frigate config migration...")
@@ -775,9 +794,44 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
return new_config
def rename_hailo_detector(
config: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
"""Rename the hailo8l detector, which drives every Hailo device.
Args:
config: The loaded config
Returns:
The config with every models entry naming the detector 'hailo'
"""
new_config = config.copy()
for model in new_config.get("models") or []:
if not isinstance(model, dict):
continue
devices = model.get("devices")
if not isinstance(devices, list):
continue
# assigned per index so ruamel keeps the comments on the list
for index, device in enumerate(devices):
if not isinstance(device, str):
continue
detector, separator, rest = device.partition(":")
if detector == "hailo8l":
devices[index] = f"hailo{separator}{rest}"
return new_config
def migrate_019_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Handle migrating Frigate config to 0.19-0."""
new_config = config.copy()
new_config = rename_hailo_detector(config)
_migrate_birdseye_mode(new_config.get("birdseye"))
@@ -822,6 +876,11 @@ def migrate_models(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any
detector = detector or {}
detector_type = detector.get("type", "cpu")
device = detector.get(DETECTOR_DEVICE_FIELDS.get(detector_type, "device"))
# hailo8l named one device, but the detector drives every Hailo device
if detector_type == "hailo8l":
detector_type = "hailo"
device_string = detector_type if device is None else f"{detector_type}:{device}"
# repeating a device now means running an extra inference process on it,
@@ -49,9 +49,43 @@ const PLUS_MODEL = {
height: 320,
};
// Frigate+ builds a Hailo model per device and names the detector by its
// pre-rename key; /api/plus/models adds the current one before serving it
const HAILO_PLUS_MODELS = [
{
...PLUS_MODEL,
id: "hailo8l1",
supportedDetectors: ["hailo8l", "hailo"],
hailoDevice: "hailo8l",
},
{
...PLUS_MODEL,
id: "hailo8r1",
supportedDetectors: ["hailo8l", "hailo"],
hailoDevice: "hailo8r",
},
];
const HAILO_HARDWARE = [
{
key: "hailo",
detector: "hailo",
name: "Hailo",
units: [{ device: "hailo:PCIe", label: "hailo0" }],
count: 1,
unlimited: true,
},
];
type SavedConfig = { config_data?: { models?: Model[] } };
async function installRoutes(page: Page, models: Model[], plusEnabled = false) {
async function installRoutes(
page: Page,
models: Model[],
plusEnabled = false,
plusModels: unknown[] = [PLUS_MODEL],
hailoHardware = false,
) {
const config = configFactory({
models,
plus: { enabled: plusEnabled },
@@ -70,8 +104,14 @@ async function installRoutes(page: Page, models: Model[], plusEnabled = false) {
route.fulfill({ json: { models } }),
);
await page.route("**/api/plus/models", (route) =>
route.fulfill({ json: [PLUS_MODEL] }),
route.fulfill({ json: plusModels }),
);
if (hailoHardware) {
await page.route("**/api/hardware/probe**", (route) =>
route.fulfill({ json: HAILO_HARDWARE }),
);
}
await page.route("**/api/config/set", async (route) => {
saves.push(route.request().postDataJSON() as SavedConfig);
await route.fulfill({ json: { success: true, require_restart: false } });
@@ -262,6 +302,35 @@ test.describe("Detection models settings @high", () => {
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
});
test("a Frigate+ Hailo model is listed by the device it was built for", async ({
frigateApp,
}) => {
// every Hailo model supports the one hailo detector, so the detector name
// says nothing; which device it was built for is what the user picks on
await installRoutes(
frigateApp.page,
[{ scene: "all", devices: ["hailo:PCIe"], path: "/config/custom.hef" }],
true,
HAILO_PLUS_MODELS,
true,
);
await openPage(frigateApp);
await frigateApp.page.getByRole("tab", { name: "Frigate+" }).click();
await frigateApp.page.getByRole("combobox").last().click();
const options = frigateApp.page.getByRole("option");
await expect(options).toHaveCount(2);
await expect(options.first()).toContainText("hailo8l");
await expect(options.last()).toContainText("hailo8r");
// knowing which device is attached is left to the user, so neither is
// ruled out here
await expect(options.first()).not.toHaveAttribute("aria-disabled", "true");
await expect(options.last()).not.toHaveAttribute("aria-disabled", "true");
});
test("a freshly opened page is not reported as modified", async ({
frigateApp,
}) => {
@@ -0,0 +1,141 @@
/**
* Object filters settings tests -- MEDIUM tier.
*
* `objects.filters` is an additionalProperties map, so each label's filter is
* an entry RJSF adds at runtime. RJSF has changed how a cleared field nested
* inside such an entry is stored (`""` before 6.9, omitted after). These tests
* pin what Frigate does with it: the save payload deletes only the cleared
* key, and restoring the value leaves the section clean.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const SETTINGS_URL = "/settings?page=cameraObjects&camera=front_door";
const UNSAVED = "You have unsaved changes";
// A non-default value can only come from the YAML, so deleting it is safe
const MIN_AREA = 5000;
async function installRoutes(page: Page) {
const config = configFactory({
cameras: {
front_door: {
objects: {
filters: {
person: {
min_area: MIN_AREA,
max_area: 24000000,
min_ratio: 0,
max_ratio: 24000000,
threshold: 0.7,
min_score: 0.5,
},
},
},
},
},
});
let lastSavedConfig: unknown = null;
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({ json: config });
}
return route.fulfill({ json: { success: true } });
});
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: {} }),
);
await page.route("**/api/config/set", async (route) => {
lastSavedConfig = route.request().postDataJSON();
await route.fulfill({ json: { success: true, require_restart: false } });
});
return { capturedConfig: () => lastSavedConfig };
}
async function openPersonMinArea(page: Page) {
await page.getByText("Object filters", { exact: true }).click();
await page.locator('[aria-expanded="false"]', { hasText: /^Person/ }).click();
const minArea = page.getByRole("textbox", { name: "Minimum object area" });
await expect(minArea).toHaveValue(String(MIN_AREA));
return minArea;
}
test.describe("object filters additionalProperties entries @medium", () => {
test("clearing a nested filter field deletes only that key on save", async ({
frigateApp,
}) => {
const capture = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
const minArea = await openPersonMinArea(frigateApp.page);
await minArea.fill("");
await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible();
await frigateApp.page
.getByRole("button", { name: "Save", exact: true })
.click();
// Empty string is the backend's remove sentinel. Sibling filter fields
// must not appear, or the save would rewrite values the user didn't touch.
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
cameras: {
front_door: { objects: { filters: { person: { min_area: "" } } } },
},
},
});
const saved = capture.capturedConfig() as {
config_data: {
cameras: {
front_door: { objects: { filters: { person: object } } };
};
};
};
expect(saved.config_data.cameras.front_door.objects.filters.person).toEqual(
{ min_area: "" },
);
});
test("restoring a cleared nested filter field leaves the section clean", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
const minArea = await openPersonMinArea(frigateApp.page);
const save = frigateApp.page.getByRole("button", {
name: "Save",
exact: true,
});
await minArea.fill("");
await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible();
await expect(save).toBeEnabled();
await minArea.fill(String(MIN_AREA));
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
await expect(save).toBeDisabled();
});
});
+2 -2
View File
@@ -422,7 +422,7 @@ test.describe("System — Health hardware pane @medium", () => {
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["hailo8l"] }] },
config: { models: [{ scene: "all", devices: ["hailo"] }] },
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
@@ -431,7 +431,7 @@ test.describe("System — Health hardware pane @medium", () => {
await expect(row).toHaveAttribute("data-state", "error", {
timeout: 15_000,
});
await expect(row).toContainText("hailo8l was not found on this system");
await expect(row).toContainText("hailo was not found on this system");
});
test("a generic device the probe cannot enumerate is judged by its runtime", async ({
+617 -2772
View File
File diff suppressed because it is too large Load Diff
+50 -50
View File
@@ -25,102 +25,102 @@
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^3.10.0",
"@melloware/react-logviewer": "^6.1.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.15",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-context-menu": "^2.3.7",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-hover-card": "^1.1.23",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-radio-group": "^1.4.7",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slider": "^1.4.7",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.2.8",
"@rjsf/core": "^6.4.1",
"@rjsf/shadcn": "^6.5.2",
"@rjsf/utils": "^6.4.1",
"@rjsf/validator-ajv8": "^6.4.1",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toggle": "^1.1.18",
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@rjsf/core": "^6.10.0",
"@rjsf/shadcn": "^6.10.0",
"@rjsf/utils": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"apexcharts": "^7.3.0",
"axios": "^1.18.0",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"copy-to-clipboard": "^3.3.3",
"copy-to-clipboard": "^4.0.2",
"date-fns": "^3.6.0",
"date-fns-tz": "^3.2.0",
"framer-motion": "^13.2.0",
"hls.js": "^1.6.15",
"framer-motion": "^13.3.0",
"hls.js": "^1.7.3",
"i18next": "^24.2.0",
"i18next-http-backend": "^4.0.2",
"idb-keyval": "^6.2.1",
"idb-keyval": "^6.3.0",
"js-yaml": "^4.3.2",
"konva": "^10.2.3",
"konva": "^10.5.0",
"lodash": "^4.18.1",
"lucide-react": "^0.577.0",
"monaco-yaml": "^5.4.1",
"lucide-react": "^1.46.0",
"monaco-yaml": "^5.5.1",
"next-themes": "^0.4.6",
"nosleep.js": "^0.12.0",
"react": "^19.2.4",
"react": "^19.3.0",
"react-apexcharts": "^2.1.1",
"react-day-picker": "^9.14.0",
"react-device-detect": "^2.2.3",
"react-dom": "^19.2.4",
"react-dom": "^19.3.0",
"react-dropzone": "^14.3.8",
"react-grid-layout": "^2.2.2",
"react-hook-form": "^7.72.0",
"react-grid-layout": "^2.2.4",
"react-hook-form": "^7.88.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.6.0",
"react-konva": "^19.2.3",
"react-icons": "^5.7.0",
"react-konva": "^19.2.7",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.30.6",
"react-swipeable": "^7.0.2",
"react-zoom-pan-pinch": "3.4.4",
"remark-gfm": "^4.0.0",
"scroll-into-view-if-needed": "^3.1.0",
"sonner": "^2.0.7",
"sonner": "^2.0.8",
"sort-by": "^1.2.0",
"strftime": "^0.10.3",
"swr": "^2.4.1",
"swr": "^2.5.1",
"tailwind-merge": "^2.4.0",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.2.0",
"use-long-press": "^3.3.0",
"vaul": "^1.1.2",
"virtua": "^0.51.3",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.23.8"
"zod": "^4.6.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.59.1",
"@tailwindcss/forms": "^0.5.9",
"@playwright/test": "^1.63.0",
"@tailwindcss/forms": "^0.5.11",
"@testing-library/jest-dom": "^6.6.2",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.12",
"@types/lodash": "^4.17.25",
"@types/node": "^25.9.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/strftime": "^0.9.8",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.4.20",
"autoprefixer": "^10.6.0",
"esbuild": "^0.28.2",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.6",
"eslint-plugin-react-refresh": "^0.5.7",
"fake-indexeddb": "^6.0.0",
"globals": "^17.12.0",
"i18next-cli": "^1.5.11",
+5 -3
View File
@@ -92,10 +92,12 @@ function PagePanel({ failure }: PanelProps) {
const stale = isStaleAsset(failure);
const onCopy = () => {
// copy() falls back to a prompt and returns false when the clipboard is
const onCopy = async () => {
// copy() falls back to a prompt and resolves false when the clipboard is
// refused, so a success toast has to wait on the result.
const copied = copy(crashReport(failure, stats?.service.version));
const copied = await copy(crashReport(failure, stats?.service.version), {
fallbackToPrompt: true,
});
if (copied) {
toast.success(t("button.copiedToClipboard"), { position: "top-center" });
+2 -2
View File
@@ -71,10 +71,10 @@ export function MessageBubble({
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
const handleCopy = useCallback(async () => {
const text = content?.trim() || "";
if (!text) return;
if (copy(text)) {
if (await copy(text)) {
setCopied(true);
toast.success(t("button.copiedToClipboard", { ns: "common" }));
setTimeout(() => setCopied(false), 2000);
@@ -12,6 +12,7 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { FrigateConfig } from "@/types/frigateConfig";
import { describeSupportedDetectors } from "@/utils/plusModels";
export type FrigatePlusModel = {
id: string;
@@ -20,6 +21,8 @@ export type FrigatePlusModel = {
trainDate: string;
isBaseModel: boolean;
supportedDetectors: string[];
// which Hailo device a Hailo model was built for, absent on every other model
hailoDevice?: string;
width: number;
height: number;
};
@@ -152,7 +155,7 @@ export function ModelSourcePicker({
<div>{describe(model)}</div>
<div className="text-xs text-muted-foreground">
{t("frigatePlus.modelInfo.supportedDetectors")}:{" "}
{model.supportedDetectors.join(", ")}
{describeSupportedDetectors(model)}
</div>
</SelectItem>
))
@@ -1,91 +0,0 @@
import { useRef, useCallback, useEffect, type ReactNode } from "react";
import { ScrollFollow } from "@melloware/react-logviewer";
export type ScrollFollowProps = {
startFollowing?: boolean;
render: (renderProps: ScrollFollowRenderProps) => ReactNode;
onCustomScroll?: (
scrollTop: number,
scrollHeight: number,
clientHeight: number,
) => void;
};
export type ScrollFollowRenderProps = {
follow: boolean;
onScroll: (args: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) => void;
startFollowing: () => void;
stopFollowing: () => void;
onCustomScroll?: (
scrollTop: number,
scrollHeight: number,
clientHeight: number,
) => void;
};
const SCROLL_BUFFER = 5;
export default function EnhancedScrollFollow(props: ScrollFollowProps) {
const followRef = useRef(props.startFollowing || false);
const prevScrollTopRef = useRef<number | undefined>(undefined);
useEffect(() => {
prevScrollTopRef.current = undefined;
}, []);
const wrappedRender = useCallback(
(renderProps: ScrollFollowRenderProps) => {
const wrappedOnScroll = (args: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) => {
// Check if scrolling up and immediately stop following
if (
prevScrollTopRef.current !== undefined &&
args.scrollTop < prevScrollTopRef.current
) {
if (followRef.current) {
renderProps.stopFollowing();
followRef.current = false;
}
}
const bottomThreshold =
args.scrollHeight - args.clientHeight - SCROLL_BUFFER;
const isNearBottom = args.scrollTop >= bottomThreshold;
if (isNearBottom && !followRef.current) {
renderProps.startFollowing();
followRef.current = true;
} else if (!isNearBottom && followRef.current) {
renderProps.stopFollowing();
followRef.current = false;
}
prevScrollTopRef.current = args.scrollTop;
renderProps.onScroll(args);
if (props.onCustomScroll) {
props.onCustomScroll(
args.scrollTop,
args.scrollHeight,
args.clientHeight,
);
}
};
return props.render({
...renderProps,
onScroll: wrappedOnScroll,
follow: followRef.current,
});
},
[props],
);
return <ScrollFollow {...props} render={wrappedRender} />;
}
@@ -10,6 +10,7 @@ import { MdHome } from "react-icons/md";
import { Button, buttonVariants } from "../ui/button";
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
@@ -66,7 +67,8 @@ import { z } from "zod";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { deleteUserNamespacedKey } from "@/hooks/use-user-persistence";
import { AuthContext } from "@/context/auth-context";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
import * as LuIcons from "react-icons/lu";
@@ -499,9 +501,8 @@ function NewGroupDialog({
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
const [isLoading, setIsLoading] = useState(false);
const [, , , deleteGridLayout] = useUserPersistence(
`${activeGroup}-draggable-layout`,
);
const { auth } = useContext(AuthContext);
const username = auth?.user?.username;
useEffect(() => {
if (!open) {
@@ -513,15 +514,14 @@ function NewGroupDialog({
const onDeleteGroup = useCallback(
async (name: string) => {
deleteGridLayout();
deleteGroup();
await axios
.put(`config/set?camera_groups.${name}`, { requires_restart: 0 })
.then((res) => {
if (res.status === 200) {
deleteUserNamespacedKey(`${name}-draggable-layout`, username);
if (activeGroup == name) {
// deleting current group
deleteGroup();
setGroup("default");
}
updateConfig();
@@ -557,15 +557,7 @@ function NewGroupDialog({
setIsLoading(false);
});
},
[
updateConfig,
activeGroup,
setGroup,
setOpen,
deleteGroup,
deleteGridLayout,
t,
],
[updateConfig, activeGroup, setGroup, setOpen, deleteGroup, username, t],
);
const onSave = () => {
@@ -203,7 +203,8 @@ export function PolygonCanvas({
if (stage) {
// we add an unfilled line for adding points when finished
const index = e.target.index - (activePolygon.isFinished ? 2 : 1);
let pos = [e.target._lastPos!.x, e.target._lastPos!.y];
const dragged = e.target.getAbsolutePosition();
let pos = [dragged.x, dragged.y];
if (snapPoints) {
// Snap to other polygons' edges
-5
View File
@@ -184,8 +184,3 @@ html {
.react-grid-layout .react-grid-item {
transition: none !important;
}
.react-lazylog,
.react-lazylog-searchbar {
background-color: transparent !important;
}
+1 -1
View File
@@ -123,7 +123,7 @@ function ConfigEditor() {
hover: true,
completion: true,
validate: true,
format: true,
format: { enable: true },
schemas: [
{
uri: `${apiHost}api/config/schema.json`,
+200 -168
View File
@@ -8,7 +8,13 @@ import {
logTypes,
} from "@/types/log";
import copy from "copy-to-clipboard";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import axios from "axios";
import LogInfoDialog from "@/components/overlay/LogInfoDialog";
import { LogChip } from "@/components/indicators/Chip";
@@ -21,34 +27,110 @@ import { cn } from "@/lib/utils";
import { parseLogLines } from "@/utils/logUtil";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import scrollIntoView from "scroll-into-view-if-needed";
import { LazyLog } from "@melloware/react-logviewer";
import { VList, type VListHandle } from "virtua";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import EnhancedScrollFollow from "@/components/dynamic/EnhancedScrollFollow";
import { MdCircle } from "react-icons/md";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { debounce } from "lodash";
import { isIOS, isMobile } from "react-device-detect";
import { isDesktop, isIOS, isMobile } from "react-device-detect";
import { isPWA } from "@/utils/isPWA";
import { isInIframe } from "@/utils/isIFrame";
import { useTranslation } from "react-i18next";
import WsMessageFeed from "@/components/ws/WsMessageFeed";
const OLDER_LINES_CHUNK_SIZE = 100;
const FOLLOW_THRESHOLD_PX = 40;
// Desktop row height. Without it, virtua guesses 40px and the first render
// leaves the viewport partly empty. Mobile rows are taller, and a low hint
// there shrinks the scroll room iOS gets while it defers scroll correction
const ROW_HEIGHT_HINT_PX = 29;
// Stable ids keep row measurements attached to the right line after a prepend
type LogEntry = { id: number; text: string };
// shift anchors the viewport to the end when lines are prepended. stick keeps
// the newest line in view when lines are appended
type LogState = { entries: LogEntry[]; shift: boolean; stick: boolean };
function Logs() {
const { t } = useTranslation(["views/system"]);
const [logService, setLogService] = useState<LogType>("frigate");
const isWebsocket = logService === "websocket";
const tabsRef = useRef<HTMLDivElement | null>(null);
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
const [logs, setLogs] = useState<string[]>([]);
const logWrapperRef = useRef<HTMLDivElement>(null);
const listRef = useRef<VListHandle>(null);
const [logState, setLogState] = useState<LogState>({
entries: [],
shift: false,
stick: true,
});
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
const [selectedLog, setSelectedLog] = useState<LogLine>();
const lazyLogRef = useRef<LazyLog>(null);
const [isLoading, setIsLoading] = useState(true);
const [follow, setFollow] = useState(true);
const lastFetchedIndexRef = useRef(-1);
const loadingOlderRef = useRef(false);
const firstIdRef = useRef(0);
const lastIdRef = useRef(0);
// The last wheel or key scroll went up. The view can still sit at the
// bottom for a moment, so position alone would keep following
const scrolledUpRef = useRef(false);
// lines
const isFollowing = useCallback(() => {
const list = listRef.current;
if (!list || scrolledUpRef.current) return false;
return (
list.scrollSize - list.scrollOffset - list.viewportSize <
FOLLOW_THRESHOLD_PX
);
}, []);
const resetLines = useCallback((lines: string[]) => {
firstIdRef.current = 0;
lastIdRef.current = lines.length;
setLogState({
entries: lines.map((text, id) => ({ id, text })),
shift: false,
stick: true,
});
}, []);
const appendLines = useCallback(
(lines: string[]) => {
const entries = lines
.filter((text) => text.trim())
.map((text) => ({ id: lastIdRef.current++, text }));
if (!entries.length) return;
const stick = isFollowing();
setLogState((prev) => ({
entries: [...prev.entries, ...entries],
shift: false,
stick,
}));
},
[isFollowing],
);
const prependLines = useCallback((lines: string[]) => {
firstIdRef.current -= lines.length;
const firstId = firstIdRef.current;
const entries = lines.map((text, i) => ({ id: firstId + i, text }));
setLogState((prev) => ({
entries: [...entries, ...prev.entries],
shift: true,
stick: false,
}));
}, []);
useEffect(() => {
document.title = t("documentTitle.logs." + logService);
@@ -102,8 +184,7 @@ function Logs() {
response.data &&
Array.isArray(response.data.lines)
) {
const filteredLines = filterLines(response.data.lines);
return filteredLines;
return response.data.lines as string[];
}
} catch (error) {
const errorMessage =
@@ -115,26 +196,27 @@ function Logs() {
},
);
}
return [];
return null;
},
[logService, filterLines, t],
[logService, t],
);
const fetchInitialLogs = useCallback(async () => {
setIsLoading(true);
try {
const response = await axios.get(`logs/${logService}`, {
params: { start: filterSeverity ? 0 : -100 },
params: { start: filterSeverity?.length ? 0 : -100 },
});
if (
response.status === 200 &&
response.data &&
Array.isArray(response.data.lines)
) {
const filteredLines = filterLines(response.data.lines);
setLogs(filteredLines);
resetLines(filterLines(response.data.lines));
// A filtered load fetches the whole file, so nothing older remains
lastFetchedIndexRef.current =
response.data.totalLines - filteredLines.length;
response.data.totalLines - response.data.lines.length;
}
} catch (error) {
const errorMessage =
@@ -145,7 +227,7 @@ function Logs() {
} finally {
setIsLoading(false);
}
}, [logService, filterLines, filterSeverity, t]);
}, [logService, filterLines, filterSeverity, resetLines, t]);
const abortControllerRef = useRef<AbortController | null>(null);
@@ -180,9 +262,7 @@ function Logs() {
return filterSeverity.includes(parsedLine.severity);
})
: lines;
if (filteredLines.length > 0) {
lazyLogRef.current?.appendLines(filteredLines);
}
appendLines(filteredLines);
}
// Process next chunk
return processStreamChunk(reader);
@@ -215,17 +295,19 @@ function Logs() {
);
}
});
}, [logService, filterSeverity, t]);
}, [logService, filterSeverity, appendLines, t]);
useEffect(() => {
if (isWebsocket) {
setIsLoading(false);
setLogs([]);
resetLines([]);
return;
}
setIsLoading(true);
setLogs([]);
setFollow(true);
scrolledUpRef.current = false;
resetLines([]);
lastFetchedIndexRef.current = -1;
fetchInitialLogs().then(() => {
// Start streaming after initial load
@@ -243,70 +325,52 @@ function Logs() {
// handlers
const prependLines = useCallback((newLines: string[]) => {
if (!lazyLogRef.current) return;
const loadOlderLines = useCallback(async () => {
const end = lastFetchedIndexRef.current;
if (loadingOlderRef.current || end <= 0) return;
const newLinesArray = newLines.map(
(line) => new Uint8Array(new TextEncoder().encode(line + "\n")),
);
loadingOlderRef.current = true;
const start = Math.max(0, end - OLDER_LINES_CHUNK_SIZE);
const lines = await fetchLogRange(start, end);
loadingOlderRef.current = false;
lazyLogRef.current.setState((prevState) => ({
...prevState,
lines: prevState.lines.unshift(...newLinesArray),
count: prevState.count + newLines.length,
}));
}, []);
// A service or filter change resets the index while the request is in flight
if (!lines || lastFetchedIndexRef.current !== end) return;
// debounced
const handleScroll = useMemo(
() =>
debounce(() => {
const scrollThreshold =
lazyLogRef.current?.listRef.current?.findEndIndex() ?? 10;
const startIndex =
lazyLogRef.current?.listRef.current?.findStartIndex() ?? 0;
const endIndex =
lazyLogRef.current?.listRef.current?.findEndIndex() ?? 0;
const pageSize = endIndex - startIndex;
if (
scrollThreshold < pageSize + pageSize / 2 &&
lastFetchedIndexRef.current > 0 &&
!isLoading
) {
const nextEnd = lastFetchedIndexRef.current;
const nextStart = Math.max(0, nextEnd - (pageSize || 100));
setIsLoading(true);
lastFetchedIndexRef.current = start;
prependLines(lines);
}, [fetchLogRange, prependLines]);
fetchLogRange(nextStart, nextEnd).then((newLines) => {
if (newLines.length > 0) {
prependLines(newLines);
lastFetchedIndexRef.current = nextStart;
// Runs on scroll events and on wheel or key input, because input at the top
// or bottom edge doesn't scroll and fires no scroll event
const syncScrollState = useCallback(() => {
const list = listRef.current;
if (!list) return;
lazyLogRef.current?.listRef.current?.scrollTo(
newLines.length *
lazyLogRef.current?.listRef.current?.getItemSize(1),
);
}
});
setFollow(isFollowing());
setIsLoading(false);
}
}, 50),
[fetchLogRange, isLoading, prependLines],
);
const handleCopyLogs = useCallback(() => {
if (logs.length) {
fetchInitialLogs()
.then(() => {
copy(logs.join("\n"));
toast.success(t("logs.copy.success"));
})
.catch(() => {
toast.error(t("logs.copy.error"));
});
if (list.scrollOffset < list.viewportSize) {
loadOlderLines();
}
}, [logs, fetchInitialLogs, t]);
}, [isFollowing, loadOlderLines]);
useLayoutEffect(() => {
if (isLoading || !logState.stick || !logState.entries.length) return;
listRef.current?.scrollToIndex(logState.entries.length - 1, {
align: "end",
});
}, [isLoading, logState]);
const handleCopyLogs = useCallback(async () => {
if (!logState.entries.length) return;
if (await copy(logState.entries.map((entry) => entry.text).join("\n"))) {
toast.success(t("logs.copy.success"));
} else {
toast.error(t("logs.copy.error"));
}
}, [logState, t]);
const handleDownloadLogs = useCallback(() => {
axios
@@ -329,77 +393,34 @@ function Logs() {
.catch(() => {});
}, [logService]);
const handleRowClick = useCallback(
(rowInfo: { lineNumber: number; rowIndex: number }) => {
const clickedLine = parseLogLines(logService, [
logs[rowInfo.rowIndex],
])[0];
setSelectedLog(clickedLine);
},
[logs, logService],
);
// keyboard listener
useKeyboardListener(
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
(key, modifiers) => {
if (!key || !modifiers.down || !lazyLogWrapperRef.current) {
const list = listRef.current;
if (!key || !modifiers.down || !list) {
return true;
}
const container =
lazyLogWrapperRef.current.querySelector(".react-lazylog");
const logLineHeight = container?.querySelector(".log-line")?.clientHeight;
if (!logLineHeight) {
const rowHeight = list.getItemSize(list.findItemIndex(list.scrollOffset));
if (!rowHeight) {
return true;
}
const scrollAmount = key.includes("Page")
? logLineHeight * 10
: logLineHeight;
const rows = key.includes("Page") ? 10 : 1;
const direction = key.includes("Down") ? 1 : -1;
container?.scrollBy({ top: scrollAmount * direction });
scrolledUpRef.current = direction < 0;
list.scrollBy(rowHeight * rows * direction);
syncScrollState();
return true;
},
);
// format lines
const lineBufferRef = useRef<string>("");
const formatPart = useCallback(
(text: string) => {
lineBufferRef.current += text;
if (text.endsWith("\n")) {
const completeLine = lineBufferRef.current.trim();
lineBufferRef.current = "";
if (completeLine) {
const parsedLine = parseLogLines(logService, [completeLine])[0];
return (
<LogLineData
line={parsedLine}
logService={logService}
onClickSeverity={() => setFilterSeverity([parsedLine.severity])}
onSelect={() => setSelectedLog(parsedLine)}
/>
);
}
}
return null;
},
[logService, setFilterSeverity, setSelectedLog],
);
useEffect(() => {
const handleCopy = (e: ClipboardEvent) => {
e.preventDefault();
if (!lazyLogWrapperRef.current) return;
if (!logWrapperRef.current) return;
const selection = window.getSelection();
if (!selection) return;
@@ -467,7 +488,7 @@ function Logs() {
e.clipboardData?.setData("text/plain", copyText);
};
const content = lazyLogWrapperRef.current;
const content = logWrapperRef.current;
content?.addEventListener("copy", handleCopy);
return () => {
content?.removeEventListener("copy", handleCopy);
@@ -488,7 +509,6 @@ function Logs() {
value={logService}
onValueChange={(value: LogType) => {
if (value) {
setLogs([]);
setFilterSeverity(undefined);
setLogService(value);
}
@@ -582,44 +602,56 @@ function Logs() {
</div>
</div>
<div ref={lazyLogWrapperRef} className="size-full">
<div
ref={logWrapperRef}
className="min-h-0 flex-1"
onPointerDown={() => {
scrolledUpRef.current = false;
}}
>
{isLoading ? (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
) : (
<EnhancedScrollFollow
startFollowing={!isLoading}
onCustomScroll={handleScroll}
render={({ follow, onScroll }) => (
<>
{follow && !logSettings.disableStreaming && (
<div className="absolute right-1 top-3">
<Tooltip>
<TooltipTrigger>
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
</TooltipTrigger>
<TooltipContent>{t("logs.tips")}</TooltipContent>
</Tooltip>
</div>
)}
<LazyLog
ref={lazyLogRef}
enableLineNumbers={false}
selectableLines
lineClassName="text-primary bg-background"
highlightLineClassName="bg-primary/20"
onRowClick={handleRowClick}
formatPart={formatPart}
text={logs.join("\n")}
follow={follow}
onScroll={onScroll}
loadingComponent={
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
}
loading={isLoading}
/>
</>
<>
{follow && !logSettings.disableStreaming && (
<div className="absolute right-1 top-3">
<Tooltip>
<TooltipTrigger>
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
</TooltipTrigger>
<TooltipContent>{t("logs.tips")}</TooltipContent>
</Tooltip>
</div>
)}
/>
<VList
ref={listRef}
itemSize={isDesktop ? ROW_HEIGHT_HINT_PX : undefined}
data={logState.entries}
shift={logState.shift}
onScroll={syncScrollState}
onWheel={(e) => {
if (!e.deltaY) return;
scrolledUpRef.current = e.deltaY < 0;
syncScrollState();
}}
>
{(entry) => {
const line = parseLogLines(logService, [entry.text])[0];
return (
<LogLineData
key={entry.id}
line={line}
logService={logService}
onClickSeverity={() =>
setFilterSeverity([line.severity])
}
onSelect={() => setSelectedLog(line)}
/>
);
}}
</VList>
</>
)}
</div>
</div>
+29
View File
@@ -540,6 +540,35 @@ export default function Settings() {
const [editingProfile, setEditingProfile] = useState<
Record<string, string | null>
>({});
// drop pending edits for a camera or profile that no longer exists
useEffect(() => {
if (!config) return;
const isStale = (camera: string, profile?: string | null) =>
!config.cameras[camera] || (!!profile && !config.profiles?.[profile]);
const prune = <T,>(
prev: Record<string, T>,
stale: (key: string, value: T) => boolean,
) => {
const next = Object.fromEntries(
Object.entries(prev).filter(([key, value]) => !stale(key, value)),
);
return Object.keys(next).length === Object.keys(prev).length
? prev
: next;
};
setEditingProfile((prev) => prune(prev, isStale));
setPendingDataBySection((prev) =>
prune(prev, (key) => {
const [camera, section] = key.split("::");
return (
section !== undefined &&
isStale(camera, parseProfileFromSectionPath(section).profileName)
);
}),
);
}, [config]);
const [profilesUIEnabled, setProfilesUIEnabled] = useState(false);
const allProfileNames = useMemo(() => {
+2
View File
@@ -429,6 +429,8 @@ export type DetectionModelConfig = {
baseModel: string;
isBaseModel: boolean;
supportedDetectors: string[];
// which Hailo device a Hailo model was built for, absent on every other model
hailoDevice?: string;
width: number;
height: number;
} | null;
+1 -1
View File
@@ -55,7 +55,7 @@ export function runnerNames(models: DetectionModelConfig[]): string[] {
export const PROBED_DETECTORS = new Set([
"cpu",
"edgetpu",
"hailo8l",
"hailo",
"memryx",
"openvino",
"onnx",
+17
View File
@@ -0,0 +1,17 @@
/** Matching Frigate+ models against the hardware that can run them. */
type PlusModel = {
supportedDetectors: string[];
// which Hailo device a Hailo model was built for, absent on every other model
hailoDevice?: string;
};
/**
* The detectors to show for a model.
*
* Every Hailo model supports the one hailo detector, so the device it was
* built for is what the user needs to see.
*/
export function describeSupportedDetectors(model: PlusModel): string {
return model.hailoDevice ?? model.supportedDetectors.join(", ");
}
@@ -5,6 +5,7 @@ import {
} from "@/components/card/SettingsGroupCard";
import type { DetectionModelConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next";
import { describeSupportedDetectors } from "@/utils/plusModels";
type FrigatePlusCurrentModelSummaryProps = {
plusModel: DetectionModelConfig["plus"];
@@ -61,7 +62,7 @@ export default function FrigatePlusCurrentModelSummary({
/>
<SplitCardRow
label={t("frigatePlus.modelInfo.supportedDetectors")}
content={<p>{plusModel.supportedDetectors.join(", ")}</p>}
content={<p>{describeSupportedDetectors(plusModel)}</p>}
/>
</div>
)}