mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 08:08:59 +03:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ce1ac354 | ||
|
|
10a0d5ea37 | ||
|
|
64d6366ac4 | ||
|
|
79ca18d439 | ||
|
|
cabdffea20 | ||
|
|
ba41c90c07 | ||
|
|
0c52a3175d | ||
|
|
4c648f8147 | ||
|
|
e0d4337a25 | ||
|
|
5e689f2d85 | ||
|
|
57a2765d00 | ||
|
|
dd77bae4f7 | ||
|
|
8a98d7c9b1 | ||
|
|
8a8da663c0 | ||
|
|
eddc9fccd1 | ||
|
|
84f981a77c | ||
|
|
4100383738 | ||
|
|
fa30a7e1ae | ||
|
|
eecc43ccef | ||
|
|
7821ecbb43 | ||
|
|
caa6edecac | ||
|
|
acc740a976 | ||
|
|
3931ab74a8 | ||
|
|
5be162a22c | ||
|
|
e57eeb8288 | ||
|
|
cf03df8a98 | ||
|
|
fa36fa403b | ||
|
|
069315be33 | ||
|
|
c583e21b70 |
@@ -46,9 +46,6 @@ jobs:
|
||||
- name: Build web
|
||||
run: npm run build
|
||||
working-directory: ./web
|
||||
# - name: Test
|
||||
# run: npm run test
|
||||
# working-directory: ./web
|
||||
|
||||
web_e2e:
|
||||
name: Web - E2E Tests
|
||||
|
||||
@@ -160,7 +160,7 @@ When reviewing code, do NOT comment on:
|
||||
|
||||
### Code Quality
|
||||
|
||||
- **Linting**: ESLint (see `web/.eslintrc.cjs`)
|
||||
- **Linting**: ESLint (see `web/eslint.config.js`)
|
||||
- **Formatting**: Prettier with Tailwind CSS plugin
|
||||
- **Type Safety**: TypeScript strict mode enabled
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ruff == 0.15.20
|
||||
|
||||
# types
|
||||
types-peewee == 3.17.*
|
||||
types-peewee == 4.0.*
|
||||
|
||||
@@ -3,9 +3,9 @@ click == 8.5.*
|
||||
# FastAPI
|
||||
aiohttp == 3.12.*
|
||||
starlette == 0.47.*
|
||||
starlette-context == 0.4.*
|
||||
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.*
|
||||
@@ -34,7 +34,7 @@ types-requests == 2.32.*
|
||||
norfair == 2.3.*
|
||||
setproctitle == 1.3.*
|
||||
ws4py == 0.5.*
|
||||
unidecode == 1.3.*
|
||||
unidecode == 1.4.*
|
||||
titlecase == 2.4.*
|
||||
# Image Manipulation
|
||||
numpy == 1.26.*
|
||||
@@ -61,16 +61,16 @@ rapidfuzz==3.12.*
|
||||
argcomplete==2.0.*
|
||||
contextlib2==0.6.*
|
||||
future==0.18.*
|
||||
netaddr==0.8.*
|
||||
netaddr==1.3.*
|
||||
netifaces==0.10.*
|
||||
prometheus-client == 0.21.*
|
||||
prometheus-client == 0.26.*
|
||||
# TFLite
|
||||
tflite_runtime @ https://github.com/frigate-nvr/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_x86_64.whl; platform_machine == 'x86_64'
|
||||
tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_aarch64.whl; platform_machine == 'aarch64'
|
||||
# audio transcription
|
||||
sherpa-onnx==1.12.*
|
||||
sherpa-onnx==1.13.*
|
||||
faster-whisper==1.2.*
|
||||
librosa==0.11.*
|
||||
soundfile==0.13.*
|
||||
# Memory profiling
|
||||
memray == 1.15.*
|
||||
memray == 1.20.*
|
||||
|
||||
@@ -343,13 +343,6 @@ http {
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /fonts/ {
|
||||
access_log off;
|
||||
expires 1y;
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /locales/ {
|
||||
access_log off;
|
||||
include security_headers.conf;
|
||||
@@ -376,7 +369,7 @@ http {
|
||||
sub_filter '"/BASE_PATH/assets/' '"$http_x_ingress_path/assets/';
|
||||
sub_filter '"/BASE_PATH/locales/' '"$http_x_ingress_path/locales/';
|
||||
sub_filter '"/BASE_PATH/monacoeditorwork/' '"$http_x_ingress_path/assets/';
|
||||
sub_filter 'return"/BASE_PATH/"' 'return window.baseUrl';
|
||||
sub_filter 'return`/BASE_PATH/`' 'return window.baseUrl';
|
||||
sub_filter '<body>' '<body><script>window.baseUrl="$http_x_ingress_path/";</script>';
|
||||
sub_filter_types text/css application/javascript;
|
||||
sub_filter_once off;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Nvidia ONNX Runtime GPU Support
|
||||
--extra-index-url 'https://pypi.nvidia.com'
|
||||
cython==3.0.*; platform_machine == 'x86_64'
|
||||
nvidia-cuda-cupti-cu12==12.8.90; platform_machine == 'x86_64'
|
||||
nvidia-cublas-cu12==12.8.4.1; platform_machine == 'x86_64'
|
||||
nvidia-cudnn-cu12==9.8.0.87; platform_machine == 'x86_64'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,24 @@ WebRTC may use an external STUN server for NAT traversal. MSE and HLS streaming
|
||||
|
||||
:::
|
||||
|
||||
### Selecting a streaming technology
|
||||
|
||||
Frigate [defaults to MSE](#why-does-frigate-prefer-mse-over-webrtc-for-live-view) for restreamed cameras by design. To use WebRTC, select it explicitly from a camera's single-camera Live view settings (the settings menu in the camera's Live view header on desktop, or the settings drawer on mobile). Three related controls work together:
|
||||
|
||||
- **Stream**: _what_ to play. This lists the [streams you've configured](#setting-streams-for-live-ui) (for example `Main Stream` and `Sub Stream`).
|
||||
- **Force low-bandwidth mode**: a switch that always plays Frigate's built-in low-bandwidth feed (the stream assigned the `detect` role, using JSMpeg) instead of the selected stream. It works anywhere without go2rtc and is useful on slow or metered connections. While it is enabled, the stream and streaming technology selectors are disabled; your stream and technology choices are restored when you turn it off.
|
||||
- **Streaming Technology**: _how_ to play the selected stream, listing **MSE** and **WebRTC**. It is only shown for a restreamed stream.
|
||||
|
||||
- The choices are saved **per device, per camera** in your browser's local storage.
|
||||
- **WebRTC is only selectable when it can actually work for that stream.** When it can't, the option is shown disabled with the reason inline, and a more detailed reason (the failing codecs, or why the connectivity check failed) is logged to your browser's console. Common reasons:
|
||||
- **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc` (see [WebRTC extra configuration](#webrtc-extra-configuration)).
|
||||
- **Could not connect**: e.g. port `8555` isn't reachable, or a STUN/TURN server is misconfigured. Frigate runs a one-time WebRTC connectivity check when the Live view opens; the option may briefly show as "checking" while it runs.
|
||||
- **Unsupported video codec**: the stream's video codec can't be played over WebRTC in your browser, most commonly H.265/HEVC in Firefox or Edge.
|
||||
- **Unsupported audio codec**: WebRTC needs opus or G.711 audio, so a stream whose playback audio is only AAC (without an added opus/G.711 track) can't carry audio over WebRTC. See [Audio Support](#audio-support) for how to add one.
|
||||
- **Unsupported browser**: the browser doesn't support WebRTC.
|
||||
|
||||
When WebRTC isn't available, Frigate automatically uses MSE (or falls back to JSMpeg), so live view keeps working regardless of the selection.
|
||||
|
||||
### Camera Settings Recommendations
|
||||
|
||||
If you are using go2rtc, you should adjust the following settings in your camera's firmware for the best experience with Live view:
|
||||
@@ -157,6 +175,17 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req
|
||||
- stun:8555
|
||||
```
|
||||
|
||||
- The web UI uses the STUN and TURN servers in `ice_servers` and falls back to Google's public STUN server when none are set:
|
||||
|
||||
```yaml title="config.yml"
|
||||
go2rtc:
|
||||
webrtc:
|
||||
ice_servers:
|
||||
- urls: [turn:turn.example.com:3478]
|
||||
username: frigate
|
||||
credential: password
|
||||
```
|
||||
|
||||
- For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.64.0.0/10` CIDR block.
|
||||
|
||||
- Note that some browsers may not support H.265 (HEVC). You can check your browser's current version for H.265 compatibility [here](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#codecs-madness).
|
||||
@@ -206,6 +235,8 @@ For devices that support two way talk, Frigate can be configured to use the feat
|
||||
- Ensure you access Frigate via https (may require [opening port 8971](/frigate/installation/#ports)).
|
||||
- For the Home Assistant Frigate card, [follow the docs](http://card.camera/#/usage/2-way-audio) for the correct source.
|
||||
|
||||
The two-way talk control in the single-camera Live view is only enabled when WebRTC is available; if WebRTC isn't configured or can't connect, the control is shown disabled.
|
||||
|
||||
To use the Reolink Doorbell with two way talk, you should use the [recommended Reolink configuration](/configuration/camera_specific#reolink-cameras)
|
||||
|
||||
As a starting point to check compatibility for your camera, view the list of cameras supported for two-way talk on the [go2rtc repository](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#two-way-audio). For cameras in the category `ONVIF Profile T`, you can use the [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/)'s FeatureList to check for the presence of `AudioOutput`. A camera that supports `ONVIF Profile T` _usually_ supports this, but due to inconsistent support, a camera that explicitly lists this feature may still not work. If no entry for your camera exists on the database, it is recommended not to buy it or to consult with the manufacturer's support on the feature availability.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -204,11 +204,20 @@ Light guidelines and advice:
|
||||
npm run lint
|
||||
```
|
||||
|
||||
- Add to unit tests and ensure they pass. As much as possible, you should strive to _increase_ test coverage whenever making changes. This will help ensure features do not accidentally become broken in the future.
|
||||
- If you run into error messages like "TypeError: Cannot read properties of undefined (reading 'context')" when running tests, this may be due to these issues (https://github.com/vitest-dev/vitest/issues/1910, https://github.com/vitest-dev/vitest/issues/1652) in vitest, but I haven't been able to resolve them.
|
||||
- Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass.
|
||||
|
||||
```shell
|
||||
python3 -u -m unittest
|
||||
```
|
||||
|
||||
- Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior.
|
||||
|
||||
```console
|
||||
npm run test
|
||||
# First-time setup
|
||||
npx playwright install chromium
|
||||
|
||||
# Build the app and run all tests
|
||||
npm run e2e:build && npm run e2e
|
||||
```
|
||||
|
||||
- Test in different browsers. Firefox, Chrome, and Safari all have different quirks that make them unique targets to interact with.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -147,7 +147,7 @@ If an [MQTT broker](/integrations/mqtt) is configured, Frigate maintains a conne
|
||||
For [WebRTC live streaming](/configuration/live), Frigate uses STUN for NAT traversal:
|
||||
|
||||
- **go2rtc** defaults to a local STUN listener (`stun:8555`), no internet required.
|
||||
- **The web UI's WebRTC player** includes a fallback to Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet.
|
||||
- **The web UI** uses the servers in `go2rtc.webrtc.ice_servers` for its WebRTC player and for the WebRTC connectivity check it runs when the Live view loads. If none are set, it uses Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet access from the browser. Set `ice_servers` to a STUN or TURN server on your network to avoid this.
|
||||
|
||||
## Home Assistant Supervisor
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1632
-3641
File diff suppressed because it is too large
Load Diff
+9
-9
@@ -18,16 +18,16 @@
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.7.0",
|
||||
"@docusaurus/plugin-content-docs": "^3.7.0",
|
||||
"@docusaurus/preset-classic": "^3.7.0",
|
||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||
"@docusaurus/core": "^3.10.2",
|
||||
"@docusaurus/plugin-content-docs": "^3.10.2",
|
||||
"@docusaurus/preset-classic": "^3.10.2",
|
||||
"@docusaurus/theme-mermaid": "^3.10.2",
|
||||
"@inkeep/docusaurus": "^2.0.16",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"clsx": "^2.1.1",
|
||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
"js-yaml": "^4.3.2",
|
||||
"marked": "^16.4.2",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
@@ -48,11 +48,11 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.7.0",
|
||||
"@docusaurus/types": "^3.7.0",
|
||||
"@docusaurus/module-type-aliases": "^3.10.2",
|
||||
"@docusaurus/types": "^3.10.2",
|
||||
"@types/react": "^18.3.27"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
"node": ">=20.19"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-9
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
@@ -253,7 +253,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
# Crop snapshot based on region
|
||||
# provide full image if region doesn't exist (manual events)
|
||||
height, width = img.shape[:2]
|
||||
x1_rel, y1_rel, width_rel, height_rel = event.data.get( # type: ignore[attr-defined]
|
||||
x1_rel, y1_rel, width_rel, height_rel = event.data.get(
|
||||
"region", [0, 0, 1, 1]
|
||||
)
|
||||
x1, y1 = int(x1_rel * width), int(y1_rel * height)
|
||||
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import cv2
|
||||
from peewee import DoesNotExist
|
||||
@@ -528,7 +528,8 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
.get()
|
||||
)
|
||||
|
||||
time_in_segment = ts - recording.start_time
|
||||
# start_time is a DateTimeField holding a unix timestamp
|
||||
time_in_segment = ts - cast(float, recording.start_time)
|
||||
return get_image_from_recording(
|
||||
self.config.ffmpeg,
|
||||
recording.path,
|
||||
|
||||
@@ -237,7 +237,7 @@ class SemanticTriggerProcessor(PostProcessorApi):
|
||||
return
|
||||
|
||||
# Skip the event if not an object
|
||||
if event.data.get("type") != "object": # type: ignore[attr-defined]
|
||||
if event.data.get("type") != "object":
|
||||
return
|
||||
|
||||
thumbnail_bytes = get_event_thumbnail_bytes(event)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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]
|
||||
@@ -37,7 +37,7 @@ class EventCleanup(threading.Thread):
|
||||
if self.removed_camera_labels is None:
|
||||
self.removed_camera_labels = list(
|
||||
Event.select(Event.label)
|
||||
.where(Event.camera.not_in(self.camera_keys)) # type: ignore[arg-type,call-arg,misc]
|
||||
.where(Event.camera.not_in(self.camera_keys))
|
||||
.distinct()
|
||||
.execute()
|
||||
)
|
||||
@@ -89,7 +89,7 @@ class EventCleanup(threading.Thread):
|
||||
Event.thumbnail,
|
||||
)
|
||||
.where(
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
@@ -111,7 +111,7 @@ class EventCleanup(threading.Thread):
|
||||
|
||||
# update the clips attribute for the db entry
|
||||
query = Event.select(Event.id).where(
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
@@ -218,7 +218,7 @@ class EventCleanup(threading.Thread):
|
||||
Event.camera,
|
||||
)
|
||||
.where(
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.start_time < expire_after,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
@@ -249,7 +249,7 @@ class EventCleanup(threading.Thread):
|
||||
|
||||
# update the clips attribute for the db entry
|
||||
query = Event.select(Event.id).where(
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.start_time < expire_after,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
|
||||
@@ -216,11 +216,11 @@ class ExportDebugReplaySource(DebugReplaySource):
|
||||
"""
|
||||
|
||||
def __init__(self, export: Export, duration: float) -> None:
|
||||
self._camera = cast(str, export.camera)
|
||||
self._camera = export.camera
|
||||
# Export.date is declared DateTimeField but Frigate writes raw unix
|
||||
# timestamps to the column.
|
||||
self._start_ts = float(cast(Any, export.date))
|
||||
self._video_path = cast(str, export.video_path)
|
||||
self._video_path = export.video_path
|
||||
self._duration = duration
|
||||
|
||||
@property
|
||||
|
||||
@@ -109,6 +109,8 @@ class Export(Model):
|
||||
backref="exports",
|
||||
column_name="export_case_id",
|
||||
)
|
||||
# peewee adds this accessor for the export_case column at runtime
|
||||
export_case_id: str | None
|
||||
|
||||
|
||||
class ReviewSegment(Model):
|
||||
|
||||
@@ -186,7 +186,7 @@ class NoticeRegistry:
|
||||
deleted = (
|
||||
Notice.delete()
|
||||
.where(
|
||||
Notice.kind.in_(camera_kinds), # type: ignore[call-arg, arg-type, misc]
|
||||
Notice.kind.in_(camera_kinds),
|
||||
Notice.scope == camera,
|
||||
)
|
||||
.execute()
|
||||
@@ -257,7 +257,7 @@ class NoticeRegistry:
|
||||
"""Dismissed config and stream check rows, newest first."""
|
||||
rows = (
|
||||
Notice.select()
|
||||
.where(Notice.kind.in_(list(CHECK_KINDS))) # type: ignore[call-arg, arg-type, misc]
|
||||
.where(Notice.kind.in_(list(CHECK_KINDS)))
|
||||
.order_by(Notice.dismissed_at.desc())
|
||||
)
|
||||
return [{"id": row.id, "dismissed_at": row.dismissed_at} for row in rows]
|
||||
@@ -351,7 +351,7 @@ class NoticeRegistry:
|
||||
)
|
||||
Notice.delete().where(
|
||||
Notice.kind == kind,
|
||||
Notice.id.not_in(newest), # type: ignore[call-arg, misc]
|
||||
Notice.id.not_in(newest),
|
||||
).execute()
|
||||
|
||||
def _bump_occurrences(self, kind: str, count: int, now: float) -> None:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ import itertools
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Iterable
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -28,7 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _filter_reviews_for_pass(
|
||||
reviews: list[Any],
|
||||
reviews: Iterable[Any],
|
||||
now: datetime.datetime,
|
||||
alerts_days: float,
|
||||
detections_days: float,
|
||||
@@ -121,14 +122,14 @@ class RecordingCleanup(threading.Thread):
|
||||
)
|
||||
|
||||
maybe_empty_dirs = set()
|
||||
thumbs_to_delete = list(map(lambda x: x[1], expired_reviews))
|
||||
for thumb_path in thumbs_to_delete:
|
||||
thumb_path = Path(thumb_path)
|
||||
thumbs_to_delete = list(map(lambda x: x.thumb_path, expired_reviews))
|
||||
for thumb in thumbs_to_delete:
|
||||
thumb_path = Path(thumb)
|
||||
thumb_path.unlink(missing_ok=True)
|
||||
maybe_empty_dirs.add(thumb_path.parent)
|
||||
|
||||
max_deletes = 100000
|
||||
deleted_reviews_list = list(map(lambda x: x[0], expired_reviews))
|
||||
deleted_reviews_list = list(map(lambda x: x.id, expired_reviews))
|
||||
for i in range(0, len(deleted_reviews_list), max_deletes):
|
||||
ReviewSegment.delete().where(
|
||||
ReviewSegment.id << deleted_reviews_list[i : i + max_deletes]
|
||||
|
||||
@@ -14,7 +14,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import pytz # type: ignore[import-untyped]
|
||||
from pathvalidate import sanitize_filename
|
||||
@@ -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 (
|
||||
@@ -1055,7 +1056,10 @@ class RecordingExporter(threading.Thread):
|
||||
except DoesNotExist:
|
||||
return ""
|
||||
|
||||
diff = max(0.0, float(self.start_time) - float(preview.start_time))
|
||||
# start_time is a DateTimeField holding a unix timestamp
|
||||
diff = max(
|
||||
0.0, float(self.start_time) - float(cast(Any, preview.start_time))
|
||||
)
|
||||
ffmpeg_cmd = [
|
||||
"/usr/lib/ffmpeg/8.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
|
||||
"-hide_banner",
|
||||
@@ -1095,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:
|
||||
|
||||
@@ -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())]
|
||||
|
||||
|
||||
+5
-2
@@ -3,8 +3,10 @@
|
||||
import logging
|
||||
import shutil
|
||||
import threading
|
||||
from collections.abc import Iterable
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from peewee import SQL, Case, fn
|
||||
|
||||
@@ -191,14 +193,15 @@ class StorageMaintainer(threading.Thread):
|
||||
|
||||
stream_usages = {
|
||||
row["stream_type"]: row["usage"] or 0
|
||||
for row in (
|
||||
for row in cast(
|
||||
Iterable[dict[str, Any]],
|
||||
Recordings.select(
|
||||
Recordings.stream_type,
|
||||
fn.SUM(Recordings.segment_size).alias("usage"),
|
||||
)
|
||||
.where(Recordings.camera == camera, Recordings.segment_size != 0)
|
||||
.group_by(Recordings.stream_type)
|
||||
.dicts()
|
||||
.dicts(),
|
||||
)
|
||||
}
|
||||
stream_bandwidths = self.camera_storage_stats.get(camera, {}).get(
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Tests for renaming and deleting a zone through config_set's JSON body."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ruamel.yaml
|
||||
from fastapi import Request
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestConfigSetZones(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
self.minimal_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {"armed": {"friendly_name": "Armed"}},
|
||||
"snapshots": {"required_zones": ["driveway"]},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"zones": {
|
||||
"driveway": {
|
||||
"coordinates": "0,0,1,0,1,1",
|
||||
"inertia": 5,
|
||||
"filters": {"person": {"min_area": 5000}},
|
||||
},
|
||||
"porch": {"coordinates": "0,0,0.5,0,0.5,0.5"},
|
||||
},
|
||||
"review": {
|
||||
"alerts": {
|
||||
"labels": ["person"],
|
||||
"required_zones": ["driveway"],
|
||||
},
|
||||
},
|
||||
"mqtt": {"required_zones": ["driveway", "porch"]},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"zones": {
|
||||
"driveway": {
|
||||
"coordinates": "0,0,1,0,1,1",
|
||||
"objects": ["car"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
yaml = ruamel.yaml.YAML()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".yml", delete=False
|
||||
) as config_file:
|
||||
yaml.dump(self.minimal_config, config_file)
|
||||
self.config_path = config_file.name
|
||||
self.addCleanup(os.unlink, self.config_path)
|
||||
|
||||
def _create_app(self):
|
||||
publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
publisher.publisher = MagicMock()
|
||||
app = create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
publisher,
|
||||
None,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
async def mock_get_current_user(request: Request):
|
||||
return {
|
||||
"username": request.headers.get("remote-user"),
|
||||
"role": request.headers.get("remote-role"),
|
||||
}
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return ["front"]
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
return app
|
||||
|
||||
def _put(self, camera_data: dict):
|
||||
with patch("frigate.api.app.find_config_file", return_value=self.config_path):
|
||||
with AuthTestClient(self._create_app()) as client:
|
||||
return client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"cameras": {"front": camera_data}},
|
||||
"requires_restart": 0,
|
||||
"update_topic": "config/cameras/front/zones",
|
||||
},
|
||||
)
|
||||
|
||||
def _front(self) -> dict:
|
||||
with open(self.config_path) as f:
|
||||
return ruamel.yaml.YAML().load(f)["cameras"]["front"]
|
||||
|
||||
def test_rename_moves_zone_references_in_one_request(self):
|
||||
"""The new base zone exists when validation checks the moved override."""
|
||||
resp = self._put(
|
||||
{
|
||||
"zones": {
|
||||
"driveway": None,
|
||||
"front_drive": {
|
||||
"coordinates": "0,0,1,0,1,1",
|
||||
"enabled": True,
|
||||
# as /api/config returns them, with defaults filled in
|
||||
"filters": {
|
||||
"person": {
|
||||
"min_area": 5000,
|
||||
"max_area": 24000000,
|
||||
"min_ratio": 0.0,
|
||||
"max_ratio": 24000000.0,
|
||||
"threshold": 0.7,
|
||||
"min_score": 0.5,
|
||||
"mask": {},
|
||||
}
|
||||
},
|
||||
"inertia": 5,
|
||||
},
|
||||
},
|
||||
"review": {"alerts": {"required_zones": ["front_drive"]}},
|
||||
# inherited from the global snapshots config, so no camera key
|
||||
"snapshots": {"required_zones": ["front_drive"]},
|
||||
"mqtt": {"required_zones": ["front_drive", "porch"]},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"zones": {
|
||||
"driveway": None,
|
||||
"front_drive": {
|
||||
"coordinates": "0,0,1,0,1,1",
|
||||
"objects": ["car"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200, resp.json())
|
||||
front = self._front()
|
||||
self.assertEqual(list(front["zones"]), ["porch", "front_drive"])
|
||||
self.assertEqual(front["zones"]["front_drive"]["inertia"], 5)
|
||||
self.assertEqual(
|
||||
front["zones"]["front_drive"]["filters"]["person"]["min_area"], 5000
|
||||
)
|
||||
self.assertEqual(front["review"]["alerts"]["labels"], ["person"])
|
||||
self.assertEqual(front["review"]["alerts"]["required_zones"], ["front_drive"])
|
||||
self.assertEqual(front["snapshots"]["required_zones"], ["front_drive"])
|
||||
self.assertEqual(front["mqtt"]["required_zones"], ["front_drive", "porch"])
|
||||
self.assertEqual(
|
||||
dict(front["profiles"]["armed"]["zones"]),
|
||||
{"front_drive": {"coordinates": "0,0,1,0,1,1", "objects": ["car"]}},
|
||||
)
|
||||
|
||||
def test_delete_empties_lists_without_dropping_their_section(self):
|
||||
resp = self._put(
|
||||
{
|
||||
"zones": {"driveway": None},
|
||||
"review": {"alerts": {"required_zones": []}},
|
||||
"snapshots": {"required_zones": []},
|
||||
"mqtt": {"required_zones": ["porch"]},
|
||||
"profiles": {"armed": {"zones": {"driveway": None}}},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200, resp.json())
|
||||
front = self._front()
|
||||
self.assertEqual(list(front["zones"]), ["porch"])
|
||||
self.assertEqual(front["review"]["alerts"]["labels"], ["person"])
|
||||
self.assertEqual(front["review"]["alerts"]["required_zones"], [])
|
||||
self.assertEqual(front["mqtt"]["required_zones"], ["porch"])
|
||||
self.assertNotIn("driveway", front["profiles"]["armed"]["zones"] or {})
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"), [])
|
||||
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
|
||||
@@ -406,12 +406,12 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
tracked_obj.obj_data["sub_label"] = (sub_label, score)
|
||||
|
||||
if event:
|
||||
event.sub_label = sub_label # type: ignore[assignment]
|
||||
event.sub_label = sub_label
|
||||
data = event.data
|
||||
if sub_label is None:
|
||||
data["sub_label_score"] = None # type: ignore[index]
|
||||
data["sub_label_score"] = None
|
||||
elif score is not None:
|
||||
data["sub_label_score"] = score # type: ignore[index]
|
||||
data["sub_label_score"] = score
|
||||
event.data = data
|
||||
event.save()
|
||||
|
||||
@@ -440,7 +440,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
objects_list = []
|
||||
sub_labels = set()
|
||||
events = Event.select(Event.id, Event.label, Event.sub_label).where(
|
||||
Event.id.in_(detection_ids) # type: ignore[call-arg, misc]
|
||||
Event.id.in_(detection_ids)
|
||||
)
|
||||
for det_event in events:
|
||||
if det_event.sub_label:
|
||||
@@ -506,11 +506,11 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
if event:
|
||||
data = event.data
|
||||
data[field_name] = field_value # type: ignore[index]
|
||||
data[field_name] = field_value
|
||||
if field_value is None:
|
||||
data[f"{field_name}_score"] = None # type: ignore[index]
|
||||
data[f"{field_name}_score"] = None
|
||||
elif score is not None:
|
||||
data[f"{field_name}_score"] = score # type: ignore[index]
|
||||
data[f"{field_name}_score"] = score
|
||||
event.data = data
|
||||
event.save()
|
||||
|
||||
|
||||
+61
-2
@@ -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,
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:vitest-globals/recommended",
|
||||
"plugin:prettier/recommended",
|
||||
],
|
||||
env: { browser: true, es2021: true, "vitest-globals/env": true },
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
settings: {
|
||||
jest: {
|
||||
version: 27,
|
||||
},
|
||||
},
|
||||
ignorePatterns: ["*.d.ts", "/src/components/ui/*"],
|
||||
plugins: ["react-hooks", "react-refresh"],
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "error",
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
objects: "always-multiline",
|
||||
arrays: "always-multiline",
|
||||
imports: "always-multiline",
|
||||
},
|
||||
],
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"no-console": "error",
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
{
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* WebRTC streaming-technology availability gating.
|
||||
*
|
||||
* The connectivity probe needs a live go2rtc, so this covers the
|
||||
* statically-determinable gate: with no webrtc candidates/ice_servers
|
||||
* configured, the WebRTC option must be disabled in the stream-technology
|
||||
* selector (label "Streaming Technology").
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { LivePage } from "../pages/live.page";
|
||||
|
||||
// the mocked profile is admin, so useUserPersistence keys are namespaced
|
||||
const STREAMING_KEY = "streaming-settings:admin";
|
||||
|
||||
async function writeIdb(page: Page, entries: Record<string, unknown>) {
|
||||
await page.evaluate(async (data) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open("keyval-store", 1);
|
||||
request.onupgradeneeded = () =>
|
||||
request.result.createObjectStore("keyval");
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const tx = request.result.transaction("keyval", "readwrite");
|
||||
const store = tx.objectStore("keyval");
|
||||
Object.entries(data).forEach(([key, value]) => store.put(value, key));
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
};
|
||||
});
|
||||
}, entries);
|
||||
}
|
||||
|
||||
async function readIdb(page: Page, key: string) {
|
||||
return page.evaluate(async (target) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open("keyval-store", 1);
|
||||
request.onupgradeneeded = () =>
|
||||
request.result.createObjectStore("keyval");
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const tx = request.result.transaction("keyval", "readonly");
|
||||
const get = tx.objectStore("keyval").get(target);
|
||||
get.onsuccess = () => resolve(get.result ?? null);
|
||||
get.onerror = () => reject(get.error);
|
||||
};
|
||||
});
|
||||
}, key);
|
||||
}
|
||||
|
||||
test.describe("WebRTC availability gating @critical", () => {
|
||||
test("desktop: WebRTC option is disabled when no candidates or ice_servers", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop dropdown only");
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: [], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The single-camera view fetches go2rtc stream metadata once restreamed.
|
||||
// The default mock returns {} which lacks `producers` and crashes the
|
||||
// capability parser, so return a minimal valid metadata payload.
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Open the desktop camera-settings dropdown (the FaCog gear is the last
|
||||
// button-like trigger in the single-camera header).
|
||||
const gearButtons = frigateApp.page.locator("button:has(svg)");
|
||||
await gearButtons.last().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// Open the "Streaming Technology" select. Anchor on its label, then click
|
||||
// the combobox trigger that follows it within the same field container.
|
||||
const technologyTrigger = menu
|
||||
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
|
||||
.first();
|
||||
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
|
||||
await technologyTrigger.click();
|
||||
|
||||
// The Radix select content is portaled to the document body; the WebRTC
|
||||
// option must be present and disabled (aria-disabled="true").
|
||||
const webrtcOption = frigateApp.page.getByRole("option", {
|
||||
name: /WebRTC/,
|
||||
});
|
||||
await expect(webrtcOption).toBeVisible({ timeout: 3_000 });
|
||||
await expect(webrtcOption).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// Sanity check: a non-gated option (MSE) is enabled, proving the locator
|
||||
// distinguishes enabled from disabled options.
|
||||
const mseOption = frigateApp.page.getByRole("option", { name: /MSE/ });
|
||||
await expect(mseOption).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
test("the unavailable reason is logged to the console once", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Availability is consumed by several components at once, so the emitter
|
||||
// dedupes; the count asserts that dedupe, not just the message.
|
||||
const warnings: string[] = [];
|
||||
frigateApp.page.on("console", (msg) => {
|
||||
if (msg.type() === "warning" && msg.text().includes("WebRTC unavailable"))
|
||||
warnings.push(msg.text());
|
||||
});
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: [], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
await expect
|
||||
.poll(() => warnings.length, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain("WebRTC unavailable 'not-configured'");
|
||||
expect(warnings[0]).toContain("go2rtc.webrtc");
|
||||
expect(warnings[0]).toContain("docs.frigate.video");
|
||||
});
|
||||
|
||||
test("desktop: the WebRTC option reports the pending connectivity check", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop dropdown only");
|
||||
|
||||
// Hold the signaling socket open so the probe stays pending. Left alone it
|
||||
// fails fast against the preview server and resolves to unreachable, which
|
||||
// is the state the first test already covers.
|
||||
await frigateApp.page.routeWebSocket("**/live/webrtc/api/ws**", () => {
|
||||
// never answer the offer
|
||||
});
|
||||
|
||||
// Candidates configured, so the gate reaches the probe rather than
|
||||
// stopping at not-configured.
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: ["192.168.1.10:8555"], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const gearButtons = frigateApp.page.locator("button:has(svg)");
|
||||
await gearButtons.last().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
const technologyTrigger = menu
|
||||
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
|
||||
.first();
|
||||
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
|
||||
await technologyTrigger.click();
|
||||
|
||||
// Unselectable while the probe runs, but with the reason stated rather
|
||||
// than a bare greyed-out row.
|
||||
const webrtcOption = frigateApp.page.getByRole("option", {
|
||||
name: /WebRTC/,
|
||||
});
|
||||
await expect(webrtcOption).toBeVisible({ timeout: 3_000 });
|
||||
await expect(webrtcOption).toHaveAttribute("aria-disabled", "true");
|
||||
await expect(webrtcOption).toContainText(/Checking WebRTC availability/i);
|
||||
});
|
||||
|
||||
test("desktop: JSMpeg is no longer offered in the technology selector", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop dropdown only");
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: [], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const gearButtons = frigateApp.page.locator("button:has(svg)");
|
||||
await gearButtons.last().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// Technology selector offers MSE/WebRTC but NOT JSMpeg (replaced by the
|
||||
// "Force low-bandwidth mode" switch).
|
||||
const technologyTrigger = menu
|
||||
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
|
||||
.first();
|
||||
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
|
||||
await technologyTrigger.click();
|
||||
await expect(
|
||||
frigateApp.page.getByRole("option", { name: /MSE/ }),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
await expect(
|
||||
frigateApp.page.getByRole("option", { name: /JSMpeg/ }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("desktop: force low-bandwidth switch disables the technology and stream selectors", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop dropdown only");
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: [], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const gearButtons = frigateApp.page.locator("button:has(svg)");
|
||||
await gearButtons.last().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// Both selectors are present and enabled to begin with (the switch is off).
|
||||
await expect(menu.locator('label[for="streaming-mode"]')).toHaveCount(1);
|
||||
const technologyTrigger = menu
|
||||
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
|
||||
.first();
|
||||
const streamTrigger = menu
|
||||
.locator('div:has(> label[for="streaming-method"]) [role="combobox"]')
|
||||
.first();
|
||||
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
|
||||
await expect(technologyTrigger).toBeEnabled();
|
||||
await expect(streamTrigger).toBeVisible({ timeout: 3_000 });
|
||||
await expect(streamTrigger).toBeEnabled();
|
||||
|
||||
// Enabling the switch keeps both selectors visible but disables them (the
|
||||
// low-bandwidth feed ignores the chosen stream and technology).
|
||||
const lowBandwidthSwitch = menu.getByRole("switch", {
|
||||
name: "Force low-bandwidth mode",
|
||||
});
|
||||
await expect(lowBandwidthSwitch).toBeVisible({ timeout: 3_000 });
|
||||
await lowBandwidthSwitch.click();
|
||||
|
||||
await expect(menu.locator('label[for="streaming-mode"]')).toHaveCount(1);
|
||||
await expect(technologyTrigger).toBeDisabled();
|
||||
await expect(streamTrigger).toBeDisabled();
|
||||
|
||||
// Toggling it back off re-enables both.
|
||||
await lowBandwidthSwitch.click();
|
||||
await expect(technologyTrigger).toBeEnabled();
|
||||
await expect(streamTrigger).toBeEnabled();
|
||||
});
|
||||
|
||||
test("desktop: saving group streaming settings keeps an unavailable WebRTC choice", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop context menu only");
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: [], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const saved = {
|
||||
streamName: "front_door",
|
||||
streamType: "smart",
|
||||
playerMode: "webrtc",
|
||||
compatibilityMode: false,
|
||||
playAudio: false,
|
||||
volume: 1,
|
||||
};
|
||||
|
||||
await frigateApp.goto("/");
|
||||
await writeIdb(frigateApp.page, {
|
||||
[STREAMING_KEY]: { outdoor: { front_door: saved } },
|
||||
});
|
||||
await frigateApp.goto("/?group=outdoor");
|
||||
|
||||
// With no candidates the dialog resolves WebRTC to MSE for display, but
|
||||
// saving must not persist that fallback over the user's choice.
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
const menu = await live.openContextMenuOn("front_door");
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
await menu.getByText("Streaming Settings").click();
|
||||
|
||||
const dialog = frigateApp.page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Save" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
await expect
|
||||
.poll(() => readIdb(frigateApp.page, STREAMING_KEY))
|
||||
.toMatchObject({ outdoor: { front_door: { playerMode: "webrtc" } } });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("WebRTC availability gating @critical @mobile", () => {
|
||||
test("mobile: WebRTC option is disabled when no candidates or ice_servers", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile drawer only");
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: [], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
// Open the mobile camera-settings drawer. The camera controls render a
|
||||
// second dialog-popup trigger (the FaCog settings toggle) after the global
|
||||
// header menu; wait for both to exist, then click the last one.
|
||||
const dialogTriggers = frigateApp.page.locator(
|
||||
'button[aria-haspopup="dialog"]',
|
||||
);
|
||||
await expect(dialogTriggers).toHaveCount(2, { timeout: 10_000 });
|
||||
const settingsTrigger = dialogTriggers.last();
|
||||
await settingsTrigger.scrollIntoViewIfNeeded();
|
||||
await settingsTrigger.click();
|
||||
|
||||
// The drawer renders a "Streaming Technology" label above its select.
|
||||
// Anchor on that label, then open the combobox in the same field block.
|
||||
await expect(
|
||||
frigateApp.page.getByText("Streaming Technology", { exact: true }),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
const technologyTrigger = frigateApp.page
|
||||
.locator(
|
||||
'div:has(> div:text-is("Streaming Technology")) [role="combobox"]',
|
||||
)
|
||||
.first();
|
||||
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
|
||||
await technologyTrigger.click();
|
||||
|
||||
const webrtcOption = frigateApp.page.getByRole("option", {
|
||||
name: /WebRTC/,
|
||||
});
|
||||
await expect(webrtcOption).toBeVisible({ timeout: 3_000 });
|
||||
await expect(webrtcOption).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
const mseOption = frigateApp.page.getByRole("option", { name: /MSE/ });
|
||||
await expect(mseOption).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -18,15 +18,20 @@ const OUTDOOR_LAYOUT = [
|
||||
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
|
||||
];
|
||||
|
||||
// a camera as an export from before streaming technology selection carries
|
||||
// it, so tests can assert both what a pre-feature file writes and what the
|
||||
// technology adds on top
|
||||
const FRONT_DOOR_WITHOUT_TECHNOLOGY = {
|
||||
streamName: "front_door",
|
||||
streamType: "smart",
|
||||
compatibilityMode: false,
|
||||
playAudio: false,
|
||||
volume: 1,
|
||||
};
|
||||
|
||||
const STREAMING_SETTINGS = {
|
||||
outdoor: {
|
||||
front_door: {
|
||||
streamName: "front_door",
|
||||
streamType: "smart",
|
||||
compatibilityMode: false,
|
||||
playAudio: false,
|
||||
volume: 1,
|
||||
},
|
||||
front_door: { ...FRONT_DOOR_WITHOUT_TECHNOLOGY, playerMode: "webrtc" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -340,6 +345,7 @@ test.describe("UI settings import/export @medium", () => {
|
||||
garage: {
|
||||
streamName: "garage",
|
||||
streamType: "continuous",
|
||||
playerMode: "jsmpeg",
|
||||
compatibilityMode: true,
|
||||
playAudio: true,
|
||||
volume: 0.5,
|
||||
@@ -375,6 +381,77 @@ test.describe("UI settings import/export @medium", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("drops only the streaming technology when its value is unrecognized", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// a hand-edited file, or an export from a future Frigate that added a
|
||||
// technology this build does not know: the camera's other settings still
|
||||
// import rather than the whole file failing validation
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
await chooseImportFile(
|
||||
frigateApp.page,
|
||||
importPayload({
|
||||
sections: {
|
||||
layouts: {},
|
||||
streaming: {
|
||||
outdoor: {
|
||||
front_door: {
|
||||
...FRONT_DOOR_WITHOUT_TECHNOLOGY,
|
||||
playerMode: "quantum",
|
||||
},
|
||||
},
|
||||
},
|
||||
preferences: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText("Streaming settings (1 camera)"),
|
||||
).toBeVisible();
|
||||
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({
|
||||
outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY },
|
||||
});
|
||||
});
|
||||
|
||||
test("clears a stored streaming technology when the file predates it", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// import replaces whole camera objects, as it already does for streamName
|
||||
// and volume, so a pre-feature export resets the technology rather than
|
||||
// leaving the local choice in place
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
await writeIdb(frigateApp.page, { [STREAMING_KEY]: STREAMING_SETTINGS });
|
||||
await chooseImportFile(
|
||||
frigateApp.page,
|
||||
importPayload({
|
||||
sections: {
|
||||
layouts: {},
|
||||
streaming: {
|
||||
outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY },
|
||||
},
|
||||
preferences: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText("Streaming settings (1 camera)"),
|
||||
).toBeVisible();
|
||||
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({
|
||||
outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a file that is not valid JSON", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Zone rename and delete tests -- MEDIUM tier.
|
||||
*
|
||||
* A zone's name also lives in required_zones lists and profile overrides.
|
||||
* These tests pin that renaming or deleting a zone sends one config/set JSON
|
||||
* body that moves or drops every reference, with nothing in the query string,
|
||||
* and flags a restart. Editing the name alone must not rename the zone.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const SETTINGS_URL = "/settings?page=masksAndZones&camera=front_door";
|
||||
const COORDINATES = "0.1,0.1,0.5,0.1,0.5,0.5,0.1,0.5";
|
||||
|
||||
// /api/config returns zone filters with defaults filled in
|
||||
const FILTERS = {
|
||||
person: {
|
||||
min_area: 5000,
|
||||
max_area: 24000000,
|
||||
min_ratio: 0,
|
||||
max_ratio: 24000000,
|
||||
threshold: 0.7,
|
||||
min_score: 0.5,
|
||||
mask: {},
|
||||
},
|
||||
};
|
||||
|
||||
type ConfigSetRequest = { url: string; body: Record<string, unknown> };
|
||||
|
||||
async function installRoutes(page: Page) {
|
||||
const config = configFactory({
|
||||
profiles: { armed: { friendly_name: "Armed" } },
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: {
|
||||
driveway: {
|
||||
coordinates: COORDINATES,
|
||||
enabled: true,
|
||||
inertia: 3,
|
||||
loitering_time: 0,
|
||||
objects: [],
|
||||
filters: FILTERS,
|
||||
color: [128, 128, 0],
|
||||
},
|
||||
},
|
||||
review: { alerts: { required_zones: ["driveway"] } },
|
||||
snapshots: { required_zones: ["driveway"] },
|
||||
mqtt: { required_zones: ["driveway"] },
|
||||
profiles: {
|
||||
armed: {
|
||||
zones: { driveway: { coordinates: COORDINATES, inertia: 6 } },
|
||||
review: { alerts: { required_zones: ["driveway"] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const requests: ConfigSetRequest[] = [];
|
||||
|
||||
await page.route("**/api/config", (route) => route.fulfill({ json: config }));
|
||||
await page.route("**/api/config/set**", async (route) => {
|
||||
requests.push({
|
||||
url: route.request().url(),
|
||||
body: route.request().postDataJSON(),
|
||||
});
|
||||
await route.fulfill({ json: { success: true } });
|
||||
});
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
async function openZoneAction(
|
||||
page: Page,
|
||||
isMobile: boolean,
|
||||
action: "Edit" | "Delete",
|
||||
) {
|
||||
const row = page.locator("[data-index]", { hasText: "Driveway" });
|
||||
|
||||
if (isMobile) {
|
||||
await row.locator("button[aria-haspopup='menu']").click();
|
||||
await page.getByRole("menuitem", { name: action }).click();
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop shows the actions on hover
|
||||
await row.hover();
|
||||
await row.getByLabel(action, { exact: true }).click();
|
||||
}
|
||||
|
||||
test.describe("zone rename and delete @medium @mobile", () => {
|
||||
test("editing the name of a referenced zone keeps its id", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||
await frigateApp.page
|
||||
.getByRole("textbox", { name: "Name", exact: true })
|
||||
.fill("Main Driveway");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("textbox", { name: "ID", exact: true }),
|
||||
).toHaveValue("driveway");
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(new URL(requests[0].url).search).toBe("");
|
||||
expect(requests[0].body).toEqual({
|
||||
requires_restart: 0,
|
||||
update_topic: "config/cameras/front_door/zones",
|
||||
config_data: {
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: {
|
||||
driveway: {
|
||||
coordinates: COORDINATES,
|
||||
enabled: true,
|
||||
inertia: 3,
|
||||
loitering_time: 0,
|
||||
friendly_name: "Main Driveway",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("turning speed estimation on and off sends no distances delete", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// The zone has no distances in the YAML, and config/set fails to delete
|
||||
// a missing key
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||
const speedEstimation = frigateApp.page
|
||||
.getByText("Speed Estimation", { exact: true })
|
||||
.locator("..")
|
||||
.getByRole("switch");
|
||||
await speedEstimation.click();
|
||||
for (const line of ["A", "B", "C", "D"]) {
|
||||
await frigateApp.page
|
||||
.getByRole("textbox", { name: new RegExp(`^Line ${line} distance`) })
|
||||
.fill("5");
|
||||
}
|
||||
await speedEstimation.click();
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(requests[0].body).toMatchObject({
|
||||
config_data: { cameras: { front_door: { zones: { driveway: {} } } } },
|
||||
});
|
||||
const body = requests[0].body as {
|
||||
config_data: {
|
||||
cameras: { front_door: { zones: { driveway: object } } };
|
||||
};
|
||||
};
|
||||
expect(
|
||||
body.config_data.cameras.front_door.zones.driveway,
|
||||
).not.toHaveProperty("distances");
|
||||
});
|
||||
|
||||
test("renaming a referenced zone moves every reference in one request", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||
await frigateApp.page
|
||||
.getByRole("textbox", { name: "ID", exact: true })
|
||||
.fill("front_drive");
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(new URL(requests[0].url).search).toBe("");
|
||||
expect(requests[0].body).toEqual({
|
||||
requires_restart: 1,
|
||||
update_topic: "config/cameras/front_door/zones",
|
||||
config_data: {
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: {
|
||||
driveway: null,
|
||||
front_drive: {
|
||||
coordinates: COORDINATES,
|
||||
enabled: true,
|
||||
filters: FILTERS,
|
||||
inertia: 3,
|
||||
loitering_time: 0,
|
||||
},
|
||||
},
|
||||
review: { alerts: { required_zones: ["front_drive"] } },
|
||||
snapshots: { required_zones: ["front_drive"] },
|
||||
mqtt: { required_zones: ["front_drive"] },
|
||||
profiles: {
|
||||
armed: {
|
||||
review: { alerts: { required_zones: ["front_drive"] } },
|
||||
zones: {
|
||||
driveway: null,
|
||||
front_drive: { coordinates: COORDINATES, inertia: 6 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("deleting a referenced zone drops every reference in one request", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Delete");
|
||||
await frigateApp.page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(new URL(requests[0].url).search).toBe("");
|
||||
expect(requests[0].body).toEqual({
|
||||
requires_restart: 1,
|
||||
update_topic: "config/cameras/front_door/zones",
|
||||
config_data: {
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: { driveway: null },
|
||||
review: { alerts: { required_zones: [] } },
|
||||
snapshots: { required_zones: [] },
|
||||
mqtt: { required_zones: [] },
|
||||
profiles: {
|
||||
armed: {
|
||||
review: { alerts: { required_zones: [] } },
|
||||
zones: { driveway: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 ({
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import path from "node:path";
|
||||
import js from "@eslint/js";
|
||||
import { defineConfig, globalIgnores, includeIgnoreFile } from "eslint/config";
|
||||
import prettierRecommended from "eslint-plugin-prettier/recommended";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default defineConfig([
|
||||
includeIgnoreFile(path.join(import.meta.dirname, ".gitignore")),
|
||||
globalIgnores(["src/components/ui/", "**/*.d.ts", "**/*.cjs", "**/*.mjs"]),
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
prettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "error",
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"no-console": "error",
|
||||
"prettier/prettier": "warn",
|
||||
},
|
||||
},
|
||||
]);
|
||||
Generated
+2445
-6189
File diff suppressed because it is too large
Load Diff
+74
-88
@@ -7,13 +7,11 @@
|
||||
"dev": "vite --host",
|
||||
"postinstall": "patch-package",
|
||||
"build": "tsc && vite build --base=/BASE_PATH/",
|
||||
"lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore . && npm run e2e:lint",
|
||||
"lint": "eslint . && npm run e2e:lint",
|
||||
"e2e:lint": "node e2e/scripts/lint-specs.mjs",
|
||||
"lint:fix": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --fix .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"preview": "vite preview",
|
||||
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"e2e:build": "tsc && vite build --base=/",
|
||||
"e2e": "playwright test --config e2e/playwright.config.ts",
|
||||
"e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
|
||||
@@ -24,120 +22,108 @@
|
||||
},
|
||||
"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-aspect-ratio": "^1.1.2",
|
||||
"@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",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.15",
|
||||
"@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",
|
||||
"apexcharts": "^3.52.0",
|
||||
"axios": "^1.18.0",
|
||||
"@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.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^24.2.0",
|
||||
"framer-motion": "^13.3.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-http-backend": "^4.0.2",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"js-yaml": "^4.3.2",
|
||||
"konva": "^10.2.3",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"js-yaml": "^5.4.2",
|
||||
"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-apexcharts": "^1.4.1",
|
||||
"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-dropzone": "^14.3.8",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-konva": "^19.2.3",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-dropzone": "^20.1.2",
|
||||
"react-grid-layout": "^2.2.4",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "^17.0.14",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-konva": "^19.2.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"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",
|
||||
"sort-by": "^1.2.0",
|
||||
"strftime": "^0.10.3",
|
||||
"swr": "^2.4.1",
|
||||
"sonner": "^2.0.8",
|
||||
"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.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@testing-library/jest-dom": "^6.6.2",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.12",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/strftime": "^0.9.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"@vitest/coverage-v8": "^3.0.7",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.2.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.8",
|
||||
"eslint-plugin-vitest-globals": "^1.6.1",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"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.7",
|
||||
"globals": "^17.12.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^24.1.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"msw": "^2.3.5",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.12",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.4.2",
|
||||
"vitest": "^3.0.7"
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
|
||||
@@ -36,14 +36,6 @@
|
||||
"title": "Bandwidth:",
|
||||
"short": "Bandwidth"
|
||||
},
|
||||
"latency": {
|
||||
"title": "Latency:",
|
||||
"value": "{{seconds}} seconds",
|
||||
"short": {
|
||||
"title": "Latency",
|
||||
"value": "{{seconds}} sec"
|
||||
}
|
||||
},
|
||||
"totalFrames": "Total Frames:",
|
||||
"droppedFrames": {
|
||||
"title": "Dropped Frames:",
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
"lowBandwidthMode": "Low-bandwidth Mode",
|
||||
"twoWayTalk": {
|
||||
"enable": "Enable Two Way Talk",
|
||||
"disable": "Disable Two Way Talk"
|
||||
"disable": "Disable Two Way Talk",
|
||||
"requiresWebRTC": "Two-way talk requires WebRTC, which is unavailable",
|
||||
"error": {
|
||||
"microphone": "Two-way talk could not access your microphone",
|
||||
"refused": "Two-way talk failed to start. See the browser console for details."
|
||||
}
|
||||
},
|
||||
"cameraAudio": {
|
||||
"enable": "Enable Camera Audio",
|
||||
@@ -135,7 +140,8 @@
|
||||
"unavailable": "Audio is not available for this stream"
|
||||
},
|
||||
"debug": {
|
||||
"picker": "Stream selection unavailable in debug mode. Debug view always uses the stream assigned the detect role."
|
||||
"picker": "Stream selection unavailable in debug mode. Debug view always uses the stream assigned the detect role.",
|
||||
"technology": "Stream technology selection is unavailable in debug mode."
|
||||
},
|
||||
"twoWayTalk": {
|
||||
"tips": "Your device must support the feature and WebRTC must be configured for two-way talk.",
|
||||
@@ -144,11 +150,36 @@
|
||||
},
|
||||
"lowBandwidth": {
|
||||
"tips": "Live view is in low-bandwidth mode due to buffering or stream errors.",
|
||||
"resetStream": "Reset stream"
|
||||
"resetStream": "Reset stream",
|
||||
"force": {
|
||||
"label": "Force low-bandwidth mode",
|
||||
"desc": "Always play Frigate's built-in low-bandwidth feed instead of the selected stream. Works on any connection, but has lower quality and no audio."
|
||||
}
|
||||
},
|
||||
"playInBackground": {
|
||||
"label": "Play in background",
|
||||
"tips": "Enable this option to continue streaming when the player is hidden."
|
||||
},
|
||||
"mode": "Streaming Technology",
|
||||
"technology": {
|
||||
"description": "Choose your preferred streaming technology. Frigate may still fall back to low bandwidth mode on playback or network errors.",
|
||||
"name": {
|
||||
"mse": "MSE",
|
||||
"webrtc": "WebRTC",
|
||||
"jsmpeg": "JSMpeg"
|
||||
},
|
||||
"tips": {
|
||||
"mse": "Recommended default, broad compatibility and smooth playback",
|
||||
"webrtc": "Needs extra setup and isn't supported on every device"
|
||||
},
|
||||
"unavailable": {
|
||||
"browser": "Your browser does not support WebRTC.",
|
||||
"not-configured": "WebRTC is not configured. Set go2rtc webrtc candidates or ice_servers.",
|
||||
"unreachable": "WebRTC could not connect. Check that port 8555 is reachable and any STUN/TURN server is correct.",
|
||||
"video-codec": "This stream's video codec is not supported by WebRTC in this browser.",
|
||||
"audio-codec": "This stream's audio codec is not supported by WebRTC. Transcode to opus or G.711 with go2rtc.",
|
||||
"checking": "Checking WebRTC availability…"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameraSettings": {
|
||||
|
||||
@@ -52,7 +52,7 @@ self.addEventListener("push", function (event) {
|
||||
}
|
||||
})
|
||||
.then(show)
|
||||
: show(), // eslint-disable-line comma-dangle
|
||||
: show(),
|
||||
);
|
||||
} else {
|
||||
// pass
|
||||
@@ -77,7 +77,7 @@ self.addEventListener("notificationclick", (event) => {
|
||||
"X-CSRF-TOKEN": 1,
|
||||
},
|
||||
body: JSON.stringify({ ids: [event.notification.data.id] }),
|
||||
}), // eslint-disable-line comma-dangle
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -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,7 +2,7 @@ import { useEmbeddingsReindexProgress } from "@/api/ws";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-provider";
|
||||
} from "@/context/statusbar-context";
|
||||
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import StatusBarNotices from "@/components/health/StatusBarNotices";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -27,7 +27,7 @@ import { LuExternalLink } from "react-icons/lu";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
type UserAuthFormProps = React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
const { t } = useTranslation(["components/auth", "common"]);
|
||||
|
||||
@@ -39,8 +39,6 @@ export default function AutoUpdatingCameraImage({
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reloadInterval]);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
@@ -75,7 +73,7 @@ export default function AutoUpdatingCameraImage({
|
||||
const [isCached, setIsCached] = useState(false);
|
||||
|
||||
const cacheKey = useMemo(() => {
|
||||
let baseParam = "";
|
||||
let baseParam: string;
|
||||
|
||||
if (periodicCache && !isCached) {
|
||||
const date = new Date(key);
|
||||
|
||||
@@ -4,13 +4,15 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
import { useZoneFriendlyName } from "@/hooks/use-zone-friendly-name";
|
||||
|
||||
interface CameraNameLabelProps
|
||||
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
||||
interface CameraNameLabelProps extends React.ComponentPropsWithoutRef<
|
||||
typeof LabelPrimitive.Root
|
||||
> {
|
||||
camera?: string | CameraConfig;
|
||||
}
|
||||
|
||||
interface ZoneNameLabelProps
|
||||
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
||||
interface ZoneNameLabelProps extends React.ComponentPropsWithoutRef<
|
||||
typeof LabelPrimitive.Root
|
||||
> {
|
||||
zone: string;
|
||||
camera?: string;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function CameraImage({
|
||||
const ctx = canvasRef.current.getContext("2d");
|
||||
ctx?.drawImage(img, 0, 0, scaledWidth, scaledHeight);
|
||||
}
|
||||
onload && onload(event);
|
||||
onload?.(event);
|
||||
},
|
||||
[img, scaledHeight, scaledWidth, setHasLoaded, onload, canvasRef],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -163,8 +163,18 @@ export default function ClassificationModelEditDialog({
|
||||
}
|
||||
}, [isObjectModel, t]);
|
||||
|
||||
const form = useForm<ObjectFormData | StateFormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
const form = useForm<
|
||||
ObjectFormData | StateFormData,
|
||||
unknown,
|
||||
ObjectFormData | StateFormData
|
||||
>({
|
||||
resolver: zodResolver(
|
||||
formSchema as z.ZodType<
|
||||
ObjectFormData | StateFormData,
|
||||
z.ZodTypeDef,
|
||||
ObjectFormData | StateFormData
|
||||
>,
|
||||
),
|
||||
defaultValues: isObjectModel
|
||||
? ({
|
||||
enabled: model.enabled,
|
||||
@@ -386,6 +396,7 @@ export default function ClassificationModelEditDialog({
|
||||
"Unknown error";
|
||||
throw new Error(
|
||||
`Failed to rename ${oldName} to ${newName}: ${errorMessage}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function ClassificationModelWizardDialog({
|
||||
await axios.delete(
|
||||
`/classification/${wizardState.step1Data.modelName}`,
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Silently fail - user is already cancelling
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ export default function Step3ChooseExamples({
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Silently fail - unselected images will remain but won't cause issues
|
||||
// since the frontend filters out images that don't match expected format
|
||||
}
|
||||
|
||||
@@ -68,8 +68,7 @@ const review: SectionConfigOverrides = {
|
||||
position: "after",
|
||||
condition: (ctx) => {
|
||||
const genai = ctx.formData?.genai as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
if (genai?.image_source !== "recordings") return false;
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
return ctx.fullCameraConfig.record?.enabled === false;
|
||||
|
||||
@@ -17,11 +17,9 @@ export function validateDetectDimensions(
|
||||
const height = data.height;
|
||||
|
||||
const widthErrors = errors.width as
|
||||
| { addError?: (message: string) => void }
|
||||
| undefined;
|
||||
{ addError?: (message: string) => void } | undefined;
|
||||
const heightErrors = errors.height as
|
||||
| { addError?: (message: string) => void }
|
||||
| undefined;
|
||||
{ addError?: (message: string) => void } | undefined;
|
||||
|
||||
const message = t("detect.dimensionMustBeEven", { ns: "config/validation" });
|
||||
|
||||
|
||||
@@ -38,8 +38,7 @@ export default function CameraReviewClassification({
|
||||
const cameraName = formContext?.cameraName ?? selectedCamera;
|
||||
const fullFormData = formContext?.formData as JsonObject | undefined;
|
||||
const baselineFormData = formContext?.baselineFormData as
|
||||
| JsonObject
|
||||
| undefined;
|
||||
JsonObject | undefined;
|
||||
const cameraConfig = formContext?.fullCameraConfig;
|
||||
|
||||
const alertsZones = useMemo(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import axios from "axios";
|
||||
@@ -371,9 +371,9 @@ export default function NotificationsSettingsExtras({
|
||||
|
||||
const shouldFetchPubKey = Boolean(
|
||||
config &&
|
||||
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
|
||||
(watchAllEnabled ||
|
||||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
|
||||
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
|
||||
(watchAllEnabled ||
|
||||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
|
||||
);
|
||||
|
||||
const { data: publicKey } = useSWR(
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
JsonValue,
|
||||
} from "@/types/configForm";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
import {
|
||||
cameraUpdateTopicMap,
|
||||
globalCameraDefaultSections,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FieldPathList, FieldProps } from "@rjsf/utils";
|
||||
import yaml from "js-yaml";
|
||||
import { dump, load, YAMLException } from "js-yaml";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -16,7 +16,7 @@ function formatYaml(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return yaml.dump(value, { indent: 2, lineWidth: -1 }).trimEnd();
|
||||
return dump(value, { indent: 2, lineWidth: -1 }).trimEnd();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@@ -31,7 +31,7 @@ function parseYaml(text: string): {
|
||||
return { value: {}, error: undefined };
|
||||
}
|
||||
try {
|
||||
const parsed = yaml.load(trimmed);
|
||||
const parsed = load(trimmed);
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
@@ -41,7 +41,7 @@ function parseYaml(text: string): {
|
||||
}
|
||||
return { value: parsed as Record<string, unknown>, error: undefined };
|
||||
} catch (e) {
|
||||
const msg = e instanceof yaml.YAMLException ? e.reason : "Invalid YAML";
|
||||
const msg = e instanceof YAMLException ? e.reason : "Invalid YAML";
|
||||
return { value: undefined, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,8 +217,7 @@ function PlateCombobox({
|
||||
export function KnownPlatesField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
|
||||
@@ -159,8 +159,7 @@ function StreamValueCombobox({
|
||||
export function LiveStreamsField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -96,8 +96,7 @@ const getItemProperties = (
|
||||
|
||||
const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => {
|
||||
const scene = getItemProperties(itemSchema).scene as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
const values = scene?.enum;
|
||||
|
||||
return Array.isArray(values)
|
||||
|
||||
@@ -44,8 +44,7 @@ function getPropertyTitle(itemSchema: RJSFSchema | undefined, key: string) {
|
||||
export function ReplaceRulesField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
|
||||
@@ -86,8 +86,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
|
||||
const sectionI18nPrefix = formContext?.sectionI18nPrefix as
|
||||
| string
|
||||
| undefined;
|
||||
string | undefined;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
|
||||
const { t, i18n } = useTranslation([
|
||||
|
||||
@@ -25,8 +25,7 @@ export function MultiSchemaFieldTemplate<
|
||||
const { schema, selector, optionSchemaField, uiSchema } = props;
|
||||
|
||||
const uiOptions = uiSchema?.["ui:options"] as
|
||||
| UiSchema["ui:options"]
|
||||
| undefined;
|
||||
UiSchema["ui:options"] | undefined;
|
||||
const suppressMultiSchema = uiOptions?.suppressMultiSchema === true;
|
||||
|
||||
// Check if this is a simple nullable field that should be handled specially
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user