Compare commits

..
Author SHA1 Message Date
Nick RogersandGitHub 6791df7971 Fix ZMQ detector ignoring the endpoint in its device string (#24464)
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-09-24 15:44:07 -06:00
Josh HawkinsandGitHub 9664d9ceae Notices and status bar improvements (#24459)
* Notice and status bar improvements

Status bar problems added in the same pass got the same `Date.now()` id and overwrote each other, so usually only one showed. Messages now fall back to their text as the id. The desktop status bar shows the most severe message with a count of the rest that opens a popover listing all of them, and the mobile drawer stacks them vertically instead of placing them side by side.

Dismissing a notice hid it for good, so a detector that restarted again after a dismissal was never shown. Dismiss is replaced by acknowledge, which hides a notice until it happens again, and mute, which hides it permanently. Kinds that never repeat (config and stream checks, the update notice) can only be muted. `reopen_at_count` is removed since acknowledge covers the failed login case.

* move camera CPU warnings to notices

High ffmpeg and detect CPU warnings sat in the status bar with no way to dismiss them. They're now `ffmpeg_high_cpu` and `detect_high_cpu` notices, raised per episode by the same tracker as skipped detections. Also stop failed login attempts held from before an acknowledgement from reopening the notice.

* fix mypy and handle missing cpu stats in notices
2026-09-24 15:39:18 -06:00
Nick RogersandGitHub c959df32c9 Apple Silicon Macs via lighter: ONNX detector on the Neural Engine and media engine decode (#24453)
* Run ONNX models on a Mac's Neural Engine through lighter's plugin provider

lighter's lighter.sh/ane device places an ONNX Runtime plugin execution
provider in the container. When it is present, the ONNX session setup
registers it once and opens sessions on its Neural Engine device, the same
place CUDA, ROCm and OpenVINO are chosen, so the onnx detector (and any
model that is not pinned to the CPU) runs there with no configuration. The
hardware probe reports it as an onnx unit.

* docs: hardware decode on an Apple Silicon Mac under lighter

A community section on the video decoding page: lighter's lighter.sh/video
device, hwaccel_args -c:v h264_v4l2m2m, and why the Raspberry Pi presets
decode a single-stream camera in software. The detector docs link to it.

* docs: set the lighter decoder per camera when codecs are mixed

* docs: the ONNX detector on a Mac's Neural Engine under lighter

* Format the Neural Engine provider setup

* Fall back to the default providers when the Neural Engine cannot load a model

* Apple Silicon ffmpeg presets for lighter's media engine, recommended when it is present
2026-09-24 15:37:32 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c5889ef35f Bump image-size from 2.0.2 to 2.0.4 in /docs (#24462)
Bumps [image-size](https://github.com/image-size/image-size) from 2.0.2 to 2.0.4.
- [Release notes](https://github.com/image-size/image-size/releases)
- [Commits](https://github.com/image-size/image-size/commits)

---
updated-dependencies:
- dependency-name: image-size
  dependency-version: 2.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 13:48:50 -05:00
Josh HawkinsandGitHub 9443289112 bump onnxruntime, ruamel, and argcomplete (#24460) 2026-09-24 13:42:58 -05:00
007hacky007andGitHub 40f8ba1f7f Offer the full playback rate list on Safari (#24444)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-09-24 10:02:06 -05:00
markfrancisonlyandGitHub 397f5253a5 Rate events over at least one second (#24455)
* Rate events over at least one second

EventsPerSecond.eps() divided the event count by the time since start(),
which can be a few milliseconds right after a restart. Frames buffered
during an ffmpeg restart then report as 100+ fps, and the same happens to
the detector fps. Use a window of at least one second.

* Keep sub-second windows consistent

Floor the divisor at the window length when the window is shorter than a
second, so a caller with a sub-second window still gets its true rate.
2026-09-24 06:28:00 -06:00
A. AhmetGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
af0ba19196 Feat/deepx npu detector (#24336)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* feat(deepx): add DEEPX NPU detector and runtime integration.

* feat(deepx): enforce model_format requirement when ppu is enabled and add integrity checks for driver installation

* Update frigate/detectors/plugins/deepx.py

Public method lacks docstring

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Refactor DEEPX detector tests, support SSD and DAMO-YOLO

* feat(deepx): add anchor-free output decoding and corresponding tests

* Add tests and updates for DEEPX detector and refactor DEEPX accelerator code structure.

* fix: enhance model type validation and update documentation for DEEPX detector

* fix: add support for customizable score and NMS thresholds

* refactor: infer YOLO layout from the model, drop per-detector options and the dxrtd placeholder

* fix: keep only the anchor-free PPU verdict, re-read anchor-based each frame

* Update latency data for DEEPX NPU

* Expanding PPU support for DEEPX and set yolo-generic as default.

* enhance scale count resolution logic

* Extend PPU layout handling and YOLOX support to DEEPX detector

* fix: assume the largest PPU anchor table when the .dxnn has no layout

* Improve PPU decoding and introduce strides handling

* Improve PPU scope and fix box format mismatch

* Fix unnamed node issue that breaks traversal

* fix: update object detection model type description to remove outdated architecture

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-09-21 07:50:44 -05:00
63 changed files with 4261 additions and 780 deletions
+1
View File
@@ -5,3 +5,4 @@
/docker/rockchip/ @MarcA711
/docker/rocm/ @harakas
/docker/hailo8l/ @spanner3003
/docker/deepx/ @sixfab
+131
View File
@@ -0,0 +1,131 @@
#!/bin/bash
# Installs the DEEPX NPU kernel driver and the DX-RT runtime on the Docker host,
# then enables the vendor's dxrt.service. A container cannot load kernel
# modules, so this runs outside the image; the driver creates the /dev/dxrt*
# nodes and the daemon multiplexes the NPU across host and container.
#
# Driver, runtime and firmware versions must agree or inference hangs instead
# of failing at startup. The set this script installs is pinned in
# driver_version, runtime_version and firmware_version below; move them
# together, never one at a time.
#
# DEEPX NPU support in Frigate is maintained by Sixfab (https://sixfab.com).
set -euo pipefail
driver_version="v2.6.0"
# the commit the tag resolves to, since DEEPX signs neither tags nor releases
# and this is compiled and installed as root. Update both together
driver_commit="7074748e7104f470b02f517583abba652b3f05fa"
firmware_version="v2.7.4"
sudo apt-get update
sudo apt-get install -y git build-essential "linux-headers-$(uname -r)" pciutils wget
if ! lspci -d 1ff4: | grep -q .; then
echo "No DEEPX device found on the PCIe bus (lspci -d 1ff4:)."
echo "Check that the module is seated correctly before continuing."
exit 1
fi
# fetch the pinned commit rather than cloning the tag, so a retag cannot swap
# in different source. The build directory is reused so a second run after a
# failure does not stop on the directory already being there
mkdir -p dx_rt_npu_linux_driver
cd dx_rt_npu_linux_driver
git init -q
git remote get-url origin > /dev/null 2>&1 ||
git remote add origin https://github.com/DEEPX-AI/dx_rt_npu_linux_driver.git
git fetch --depth 1 origin "${driver_commit}"
git checkout -q FETCH_HEAD
fetched_commit=$(git rev-parse HEAD)
if [[ "${fetched_commit}" != "${driver_commit}" ]]; then
echo "Fetched commit ${fetched_commit} does not match pinned driver_commit ${driver_commit}."
echo "Refusing to build unverified driver source."
exit 1
fi
cd modules
sudo ./build.sh -c install --reload
sudo depmod -A
# dx_dma is the PCIe transport, dxrt_driver the NPU driver on top of it
for module in dx_dma dxrt_driver; do
if ! sudo modprobe "${module}"; then
echo "Unable to load the ${module} kernel module, common reasons are:"
echo "- Secure Boot is enabled and is rejecting the unsigned module."
echo "- The running kernel does not match the installed linux-headers."
exit 1
fi
done
if ! compgen -G "/dev/dxrt*" > /dev/null; then
echo "Modules loaded but no /dev/dxrt* device node appeared."
echo "Run ./sanity_check.sh from the driver repo to diagnose."
exit 1
fi
runtime_version="v3.4.0"
declare -A runtime_sha256=(
[amd64]="736cfef009ce9e974ab1ab610d867239d19d72a426a53e367ddcbd53297b6e20"
[arm64]="eb6107f5f02f2ad76ae89f414e8b5f346f34fbc6f0888236136853a26be6f6a0"
)
runtime_release="${runtime_version#v}"
deb_arch=$(dpkg --print-architecture)
deb_file="/tmp/libdxrt-bin_${runtime_release}_${deb_arch}.deb"
wget -qO "${deb_file}" \
"https://raw.githubusercontent.com/DEEPX-AI/dx_rt/${runtime_version}/release/${runtime_release}/libdxrt-bin_${runtime_release}_${deb_arch}.deb"
expected_sha256="${runtime_sha256[${deb_arch}]:-}"
if [[ -z "${expected_sha256}" ]]; then
echo "No pinned SHA-256 for architecture ${deb_arch}; refusing to install."
exit 1
fi
if [[ "$(sha256sum "${deb_file}" | cut -d' ' -f1)" != "${expected_sha256}" ]]; then
echo "SHA-256 mismatch for ${deb_file}; refusing to install."
exit 1
fi
sudo dpkg -i "${deb_file}"
sudo ldconfig
rm -f "${deb_file}"
sudo cp /usr/share/libdxrt-bin/service/dxrt.service /etc/systemd/system/
# With an endpoint set, dxrtd binds that path only, so the socket goes in a
# directory Frigate can mount (kept across restarts so the mount stays valid)
# and a symlink at the default /tmp path keeps host tools that do not set the
# variable working through their own fallback.
sudo mkdir -p /etc/systemd/system/dxrt.service.d
sudo tee /etc/systemd/system/dxrt.service.d/frigate.conf > /dev/null <<'UNIT'
[Service]
RuntimeDirectory=dxrt
RuntimeDirectoryMode=0755
RuntimeDirectoryPreserve=yes
Environment=DXRT_DYNAMIC_IPC_ENDPOINT=/run/dxrt/dxrt_dynamic_ipc.sock
ExecStartPost=/bin/ln -sfn /run/dxrt/dxrt_dynamic_ipc.sock /tmp/dxrt_dynamic_ipc.sock
UNIT
sudo systemctl daemon-reload
sudo systemctl enable dxrt.service
sudo systemctl restart dxrt.service
if ! sudo systemctl is-active --quiet dxrt.service; then
echo "dxrt.service did not start. Check: sudo journalctl -u dxrt.service"
exit 1
fi
echo "DEEPX driver and runtime installation complete."
echo "Driver version: $(modinfo -F version dxrt_driver) (expected ${driver_version#v})"
echo "Runtime version: ${runtime_release}"
echo "Device node(s): $(echo /dev/dxrt*)"
echo
echo "This driver expects NPU firmware ${firmware_version}. Check it with:"
echo " dxrt-cli --status"
echo "Update the module if it does not match before starting Frigate."
+3 -3
View File
@@ -27,7 +27,7 @@ pydantic == 2.10.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
pytz == 2025.*
pyzmq == 27.1.*
ruamel.yaml == 0.18.*
ruamel.yaml == 0.19.*
tzlocal == 5.2
requests == 2.33.*
types-requests == 2.32.*
@@ -43,7 +43,7 @@ opencv-contrib-python == 4.11.0.*
scipy == 1.16.*
# OpenVino & ONNX
openvino == 2025.4.*
onnxruntime == 1.22.*
onnxruntime == 1.30.*
# Embeddings
transformers == 4.45.*
# Generative AI
@@ -58,7 +58,7 @@ pyclipper == 1.4.*
shapely == 2.0.*
rapidfuzz==3.12.*
# HailoRT
argcomplete==2.0.*
argcomplete==3.7.*
contextlib2==0.6.*
future==0.18.*
netaddr==1.3.*
@@ -37,6 +37,7 @@ device_globs=(
"/dev/nvmap"
"/dev/nvidia*"
"/dev/memx*"
"/dev/dxrt*"
)
IFS=',' read -ra extra_globs <<< "${DEVICE_ACL_PATHS:-}"
+59
View File
@@ -967,6 +967,65 @@ memryx:
# The .zip file must contain:
# ├── ssdlite_mobilenet.dfp (a file ending with .dfp)
# └── ssdlite_mobilenet_post.onnx (optional; only if the model includes a cropped post-processing network)
deepx:
title: DEEPX NPU
models:
- key: yolo
label: YOLO
recommended: true
download: No model is bundled with Frigate. Download a pre-compiled YOLO `.dxnn` model from the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo) or compile your own with DX-COM, then bind-mount it into the container and point the model's `path` at it. The recommended model is `yolox-s_640x640_ppu.dxnn`. Its Post-Processing Unit (PPU) compile moves candidate selection onto the NPU, which makes it the fastest ModelZoo model measured through Frigate (about 13 ms on a DX-M1). The output layout is read from the compiled model, so anchor-based, anchor-free, NMS-in-head and PPU models (anchor-based or anchor-free) all need no extra configuration; prefer a `PPU` variant whenever the ModelZoo offers one. PPU models must be compiled with DX-COM 2.4.0 or later, which writes the head layout Frigate reads into the file.
ui: |-
Navigate to **Settings > System > Detection models** and select **DEEPX NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ---------------------------------------------------- |
| **Custom object detector model path** | `/config/model_cache/deepx/yolox-s_640x640_ppu.dxnn` |
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
| **Object detection model input width** | `640` |
| **Object detection model input height** | `640` |
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
| **Model Input Tensor Shape** | `nhwc` (Frigate's default value) |
| **Model Input D Type** | `int` (Frigate's default value) |
| **Object Detection Model Type** | `yolo-generic` |
Quantization is baked into the compiled model, so no normalization is applied on the host and the input defaults do not need to be overridden.
yaml: |-
models:
- devices:
- deepx:PCIe:0
path: /config/model_cache/deepx/yolox-s_640x640_ppu.dxnn
labelmap_path: /labelmap/coco-80.txt
model_type: yolo-generic
width: 640
height: 640
- key: yolox
label: YOLOX
recommended: false
download: No model is bundled with Frigate. Download a pre-compiled YOLOX `.dxnn` model from the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo), then bind-mount it into the container and point the model's `path` at it. The `_ppu` variant is faster and also works with the `yolo-generic` model type; the plain export needs `yolox` so its raw head is decoded.
ui: |-
Navigate to **Settings > System > Detection models** and select **DEEPX NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
| Field | Value |
| ---------------------------------------- | ---------------------------------------- |
| **Custom object detector model path** | `/config/model_cache/deepx/yolox-s_640x640.dxnn`|
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
| **Object detection model input width** | `640` |
| **Object detection model input height** | `640` |
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
| **Model Input Tensor Shape** | `nhwc` (Frigate's default value) |
| **Model Input D Type** | `int` (Frigate's default value) |
| **Object Detection Model Type** | `yolox` |
The width and height must match the resolution the `.dxnn` file was compiled for.
yaml: |-
models:
- devices:
- deepx:PCIe:0
path: /config/model_cache/deepx/yolox-s_640x640.dxnn
labelmap_path: /labelmap/coco-80.txt
model_type: yolox
width: 640
height: 640
tensorrt:
title: TensorRT
models:
+13 -11
View File
@@ -17,17 +17,19 @@ Hardware acceleration arguments tell FFmpeg to decode your camera's video stream
See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md) for details on setting up hardware acceleration for your GPU / iGPU, then select the preset that matches your hardware.
| Preset (YAML config) | UI Label | Usage | Notes |
| --------------------- | ----------------------- | --------------------------------- | --------------------------------------------------------------- |
| preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | |
| preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | |
| preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected |
| preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead |
| preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead |
| preset-nvidia | NVIDIA GPU | NVIDIA GPU | |
| preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | |
| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | |
| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode |
| Preset (YAML config) | UI Label | Usage | Notes |
| ------------------------- | ----------------------- | --------------------------------------------- | --------------------------------------------------------------- |
| preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | |
| preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | |
| preset-apple-silicon-h264 | Apple Silicon (H.264) | Apple Silicon Mac under lighter, H.264 stream | Needs the `lighter.sh/video` device |
| preset-apple-silicon-h265 | Apple Silicon (H.265) | Apple Silicon Mac under lighter, H.265 stream | Needs the `lighter.sh/video` device |
| preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected |
| preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead |
| preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead |
| preset-nvidia | NVIDIA GPU | NVIDIA GPU | |
| preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | |
| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | |
| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode |
<ConfigTabs>
<TabItem value="ui">
@@ -43,6 +43,10 @@ Frigate supports presets for optimal hardware accelerated video decoding:
- [RKNN](#rockchip-platform): Frigate can utilize the media engine in RockChip SOCs to accelerate video decoding.
**Apple Silicon Mac** <CommunityBadge />
- [lighter](#apple-silicon-mac-lighter): Frigate can utilize the media engine in Apple Silicon Macs to accelerate video decoding, when running under the lighter container runtime.
**Other Hardware**
Depending on your system, these presets may not be compatible, and you may need to use manual hwaccel args to take advantage of your hardware. More information on hardware accelerated decoding for ffmpeg can be found here: https://trac.ffmpeg.org/wiki/HWAccelIntro
@@ -533,3 +537,35 @@ output_args:
Make sure that your SoC supports hardware acceleration for your input stream and your input stream is h264 encoding. For example, if your camera streams with h264 encoding, your SoC must be able to de- and encode with it. If you are unsure whether your SoC meets the requirements, take a look at the datasheet.
:::
## Apple Silicon Mac (lighter)
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. It gives a container the Mac's media engine as a standard V4L2 decoder, backed by VideoToolbox, so Frigate decodes H.264 and H.265 streams in hardware with the ffmpeg it already ships. It works on M1 and newer Macs with lighter 0.9.2 or newer.
Give the container the video device. With Docker Compose:
```yaml {4-5}
services:
frigate:
...
devices:
- lighter.sh/video=all
```
Or with `docker run`, add `--device lighter.sh/video=all`.
Then set the preset for the codec your cameras stream. The decoder is specific to the codec, so if your cameras mix H.264 and H.265, set the preset for the most common codec globally and override it on the other cameras:
```yaml
ffmpeg:
hwaccel_args: preset-apple-silicon-h264
cameras:
garage: # an H.265 camera
ffmpeg:
hwaccel_args: preset-apple-silicon-h265
```
The presets decode on the media engine and encode the Birdseye restream and timelapses there too. Scaling to the detect resolution runs on the CPU, as ffmpeg's V4L2 decoders cannot scale.
lighter can also run object detection on the Mac's Neural Engine; see [Apple Neural Engine (lighter)](object_detectors.md#apple-neural-engine-lighter).
+79 -1
View File
@@ -24,6 +24,7 @@ Frigate supports multiple different detectors that work on different types of ha
- [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): 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.
- <CommunityBadge /> [DEEPX](#deepx-npu): The DEEPX NPU is available in m.2 format and as a HAT+ for the Raspberry Pi 5, offering broad compatibility across various platforms.
**AMD**
@@ -33,6 +34,7 @@ Frigate supports multiple different detectors that work on different types of ha
**Apple Silicon**
- [Apple Silicon](#apple-silicon-detector): Apple Silicon can run on M1 and newer Apple Silicon devices.
- <CommunityBadge /> [ONNX](#apple-neural-engine-lighter): the ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs under the lighter container runtime.
**Intel**
@@ -483,7 +485,7 @@ See [ONNX supported models](#onnx) for supported models, there are some caveats:
## ONNX
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, and TensorRT. On startup Frigate will automatically try to use a GPU if one is available.
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, TensorRT, and a Mac's Neural Engine. On startup Frigate will automatically try to use a GPU if one is available.
:::info
@@ -499,6 +501,9 @@ If the correct build is used for your GPU then the GPU will be detected and used
- Nvidia GPUs will automatically be detected and used with the ONNX detector in the `-tensorrt` Frigate image.
- Jetson devices will automatically be detected and used with the ONNX detector in the `-tensorrt-jp6` Frigate image.
- **Apple Silicon Mac** <CommunityBadge />
- The Neural Engine will automatically be detected and used with the ONNX detector when Frigate runs under lighter with its Neural Engine device. See [Apple Neural Engine (lighter)](#apple-neural-engine-lighter).
:::
:::tip
@@ -514,6 +519,22 @@ models:
:::
### Apple Neural Engine (lighter) {#apple-neural-engine-lighter}
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. A container started with its `lighter.sh/ane` device gets an ONNX Runtime execution provider that runs models on the Mac's Neural Engine, and the ONNX detector uses it automatically, with the same models and configuration as on any other hardware. It works on M1 and newer Macs with lighter 0.9.2 or newer.
Give the Frigate container the Neural Engine device. With Docker Compose:
```yaml
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:stable-standard-arm64
devices:
- lighter.sh/ane=all
```
Or with `docker run`, add `--device lighter.sh/ane=all`. Frigate then reports the Neural Engine under **Settings > System > Detection models**, and the ONNX detector's model loads on it. lighter can also decode camera streams on the Mac's media engine; see [Video Decoding](hardware_acceleration_video.md#apple-silicon-mac-lighter).
### Configuration {#configuration-onnx}
<ModelConfigDropdown detectorTitle="ONNX" models={objectDetectorsModels.onnx.models} />
@@ -612,6 +633,63 @@ For detailed instructions on compiling models, refer to the [MemryX Compiler](ht
---
## DEEPX NPU
This detector is available for use with the DEEPX NPU, both the DX-M1 M.2 module and the DX-M1M on the Sixfab AI HAT+ for the Raspberry Pi 5. The configuration below applies unchanged to either form factor. DEEPX NPU support in Frigate is developed and maintained by [Sixfab](https://sixfab.com).
See the [installation docs](../frigate/installation.md#deepx-npu) for information on installing the DEEPX kernel driver and runtime on the host and passing the NPU through to the container.
To run a model on a DEEPX NPU, list a `deepx` device on that model.
:::info
The DX-RT Python bindings are not part of the Frigate image. They are downloaded and installed into `/config/.local` the first time a DEEPX device is configured, verified against pinned checksums, and updated automatically when a Frigate release pins a new version. If the container has no internet access, see [Detector runtimes](/frigate/network_requirements#detector-runtimes) for how to provide the files yourself.
:::
### Configuration {#configuration-deepx}
<ModelConfigDropdown detectorTitle="DEEPX" models={objectDetectorsModels.deepx.models} />
Frigate does not bundle a model for this detector. Models must be compiled to DEEPX's `.dxnn` format. Two model types are supported:
- `yolo-generic` for YOLO object detection models, the recommended default. The detector reads the model's output layout from the compiled file, so anchor-based, anchor-free and NMS-in-head models all work with the same configuration, as do models compiled with DEEPX's Post-Processing Unit (PPU) support.
- `yolox` for YOLOX models compiled without PPU support, whose raw head needs Frigate's YOLOX decoder. A YOLOX model compiled with PPU support works under either `yolox` or `yolo-generic`.
The quickest way to get one is the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo), which publishes pre-compiled `.dxnn` files for a range of YOLO object detection models. Download the `.dxnn`, bind-mount it into the container, and point the model's `path` at it. Alternatively, compile your own model with the DX-COM compiler. The recommended starting point is `yolox-s_640x640_ppu.dxnn`, the fastest ModelZoo model measured through Frigate:
```yaml
models:
- devices:
- deepx:PCIe:0
path: /config/model_cache/deepx/yolox-s_640x640_ppu.dxnn
labelmap_path: /labelmap/coco-80.txt
model_type: yolo-generic
width: 640
height: 640
```
For PPU models, use a `.dxnn` compiled with DX-COM 2.4.0 or later. Frigate reads the PPU head layout the compiler writes into the file and refuses to load a PPU model without it.
`model_type` must be set to `yolo-generic` or `yolox` to match the model; `yolo-generic` is the recommended default unless the model is a raw YOLOX export. Frigate defaults it to `ssd`, which this detector does not support, so the detector refuses to start on a model that leaves it unset.
`width` and `height` must match the resolution the model was compiled for. Quantization parameters are baked into the `.dxnn` file at compile time, so no normalization is applied on the host and Frigate's default `input_tensor`, `input_pixel_format`, and `input_dtype` values do not need to be overridden.
A DEEPX device is `PCIe:<index>`, as reported on the detector settings page. The NPU daemon multiplexes across processes, so the same device may be listed more than once to run additional inference processes against it:
```yaml
models:
- devices:
- deepx:PCIe:0
- deepx:PCIe:0
```
#### Label maps
The object detection models in the DEEPX ModelZoo are trained on the standard 80-class COCO label set, so `labelmap_path` must be set to `/labelmap/coco-80.txt`. Frigate's default label map uses an extended 91-class COCO scheme, and leaving it in place will cause detections to be reported as the wrong object type. For `yolo-generic` models the label map is also what the detector uses to tell the output layout, so a label map with the wrong number of classes is reported as an error at startup.
---
## NVidia TensorRT Detector
Nvidia Jetson devices may be used for object detection using the TensorRT libraries. Due to the size of the additional libraries, this detector is only provided in images with the `-tensorrt-jp6` tag suffix, e.g. `ghcr.io/blakeblackshear/frigate:stable-tensorrt-jp6`. This detector is designed to work with Yolo models for object detection.
+42 -1
View File
@@ -65,6 +65,11 @@ Frigate supports multiple different detectors that work on different types of ha
- [Supports many model architectures](../../configuration/object_detectors#memryx-mx3)
- Runs best with tiny, small, or medium-size models
- <CommunityBadge /> [DEEPX](#deepx-npu): The DEEPX NPU is available in m.2 format and as a HAT+ for the Raspberry Pi 5, allowing for a wide range of compatibility with devices.
- [Supports YOLO model architectures](../../configuration/object_detectors#deepx-npu)
- Runs best with tiny or small size models
- Runs efficiently on low power hardware
**AMD**
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection
@@ -73,6 +78,10 @@ Frigate supports multiple different detectors that work on different types of ha
**Apple Silicon**
- [ONNX via lighter](#apple-silicon): The ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs in the lighter container runtime
- [Supports the same model architectures as the ONNX detector](../../configuration/object_detectors#apple-neural-engine-lighter)
- Runs inside the Frigate container, with no separate detector process to set up
- The recommended way to run Frigate on a Mac
- [Apple Silicon](#apple-silicon): Apple Silicon is usable on all M1 and newer Apple Silicon devices to provide efficient and fast object detection
- [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#apple-silicon-detector)
- Runs well with any size models including large
@@ -206,7 +215,13 @@ Inference is done with the `onnx` detector type. Speeds will vary greatly depend
### Apple Silicon
With the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon.
Frigate on a Mac is best run in the [lighter](https://github.com/fieldwork-ai/lighter) container runtime, where the [ONNX detector](../configuration/object_detectors.md#apple-neural-engine-lighter) runs on the Neural Engine of M1 and newer Macs from inside the Frigate container. There is no separate detector process to install or keep running, and the same container can decode video on the Mac's media engine.
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
| ---- | -------------------------------------- | ----------------------- | ---------------------- |
| M1 | t-320: 3.3 ms s-320: 7 ms s-640: 13 ms | 320: 6.6 ms | Nano-320: 38 ms |
Alternatively, with the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon.
:::warning
@@ -257,6 +272,32 @@ The MX3 is a pipelined architecture, where the maximum frames per second support
Inference speeds may vary depending on the host platform. The above data was measured on an **Intel 13700 CPU**. Platforms like Raspberry Pi, Orange Pi, and other ARM-based SBCs have different levels of processing capability, which may limit total FPS.
### DEEPX NPU
Frigate supports the DEEPX NPU in both of its form factors: the **DX-M1** M.2 module, which works on x86 (Intel/AMD) and ARM-based SBCs such as the Raspberry Pi 5, and the **DX-M1M** on the [Sixfab AI HAT+](https://docs.sixfab.com/docs/ai-hat-plus-raspberry-pi-5-quickstart) for the Raspberry Pi 5. Both use the same driver and runtime, so the configuration is identical for either one. DEEPX NPU support in Frigate is developed and maintained by [Sixfab](https://sixfab.com).
The DEEPX driver and runtime run on the Docker host rather than inside the Frigate container and must be installed before the NPU can be used. See the [installation docs](installation.md#deepx-npu) for the setup steps and [the detector docs](/configuration/object_detectors#deepx-npu) for the configuration.
Frigate does not bundle a model for this detector. Models use DEEPX's `.dxnn` format, and pre-compiled YOLO models can be downloaded from the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo). Prefer a model with a `_ppu` suffix whenever one is available for the architecture you want: these run part of the post-processing on the NPU itself and are considerably faster, roughly 2.5x for the same architecture and input size. **YOLOX-S with PPU is the recommended starting point.**
Inference times for a few recommended models, measured through Frigate's own stats on a DX-M1:
| Model | Input Size | DX-M1 Inference Time |
| ----------------- | ---------- | -------------------- |
| YOLOX-S (PPU) | 640 | ~ 13 ms |
| YOLOv9-t (PPU) | 640 | ~ 18 ms |
| YOLOv4 (PPU) | 512 | ~ 20 ms |
| YOLOX-S | 640 | ~ 34 ms |
| YOLOv9-s | 640 | ~ 39 ms |
Other ModelZoo YOLO variants are also supported but have not been measured. Inference speeds vary with the host platform, so a slower host such as a Raspberry Pi 5 will report higher times than those above.
:::note
A few ModelZoo models can not be used with Frigate: SSD models (they are trained on Pascal VOC, so their labels do not match Frigate's), DAMO-YOLO models, face and pose models, and the PPU builds of YOLOv7.
:::
### Nvidia Jetson
Jetson devices are supported via the TensorRT or ONNX detectors when running Jetpack 6. It will [make use of the Jetson's hardware media engine](/configuration/hardware_acceleration_video#nvidia-jetson) when configured with the [appropriate presets](/configuration/ffmpeg_presets#hwaccel-presets), and will make use of the Jetson's GPU and DLA for object detection when configured with the [TensorRT detector](/configuration/object_detectors#nvidia-tensorrt-detector).
+93
View File
@@ -381,6 +381,99 @@ If you can't use Docker Compose, you can run the container with something simila
Finally, configure [hardware object detection](/configuration/object_detectors#memryx-mx3) to complete the setup.
### DEEPX NPU
The DEEPX NPU is available in two form factors, and Frigate supports both:
- **DX-M1** in the M.2 2280 form factor (like an NVMe SSD), for x86 (Intel/AMD) PCs, the Raspberry Pi 5, and other ARM SBCs with an exposed PCIe M.2 slot.
- **DX-M1M** on the [Sixfab AI HAT+](https://docs.sixfab.com/docs/ai-hat-plus-raspberry-pi-5-quickstart), a HAT+ board that connects to the Raspberry Pi 5 over PCIe Gen 3 x1.
Both present the NPU through the same PCIe driver and DX-RT runtime, so the setup below and the detector configuration are identical for either one. Nothing needs to change when moving between them.
DEEPX NPU support in Frigate is developed and maintained by [Sixfab](https://sixfab.com).
#### Versions
A DEEPX install has several separately versioned pieces, and they all have to agree. The driver, the runtime, and the daemon live on the Docker host; Frigate itself carries only the Python bindings, which it downloads on first start:
| Component | Version | Installed on | Installed by |
| -------------- | -------- | ------------ | ------------------------- |
| Kernel driver | `v2.6.0` | Host | `user_installation.sh` |
| DX-RT runtime | `v3.4.0` | Host | `user_installation.sh` |
| NPU firmware | `v2.7.4` | The module | Flashed from the host |
| DX-RT bindings | `v3.4.0` | Frigate | Downloaded at first start |
:::warning
A version mismatch does not produce a startup error. It typically shows up as inference requests that are accepted but never return a result, so detections simply stop appearing while Frigate looks healthy. If that happens after a Frigate upgrade, check every version in the table before anything else.
:::
The installation script installs the DX-RT runtime on the host and enables `dxrt.service`, so the daemon starts at boot and any other program on the host can share the NPU with Frigate. Check the firmware version with `dxrt-cli --status` and update the module if it does not match the table above.
#### Installation
The DEEPX kernel driver must be installed on the host rather than in the container, because containers share the host kernel and cannot load kernel modules. Installing it creates the `/dev/dxrt*` device nodes that are passed through to Frigate. The same script installs the DX-RT runtime and enables `dxrt.service`, the daemon that owns the NPU and hands work to it on behalf of Frigate and anything else on the host.
1. Copy or download [this script](https://github.com/blakeblackshear/frigate/blob/dev/docker/deepx/user_installation.sh).
2. Ensure it has execution permissions with `sudo chmod +x user_installation.sh`
3. Run the script with `./user_installation.sh`
4. **Restart your computer** to complete driver installation.
Confirm the NPU is visible before continuing:
```bash
ls /dev/dxrt*
```
Then confirm the daemon is running and listening in `/run/dxrt`:
```bash
systemctl is-active dxrt.service
ls /run/dxrt/
```
#### Setup
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
#### Docker configuration
Frigate needs the NPU device node and the directory holding the daemon's socket:
```yaml
services:
frigate:
devices:
- /dev/dxrt0:/dev/dxrt0
volumes:
- /run/dxrt:/run/dxrt
```
If you can't use Docker Compose, add `--device /dev/dxrt0:/dev/dxrt0 -v /run/dxrt:/run/dxrt` to your `docker run` command.
Add one `--device` per NPU, contiguously from `/dev/dxrt0`, since the client stops enumerating at the first gap.
The installation script configures `dxrt.service` to place its socket in `/run/dxrt` through a systemd drop-in. Mounting the directory rather than the socket file means the container sees the new socket after `dxrt.service` is restarted, rather than holding on to a deleted one.
`dxrtd` listens on an abstract socket as well, but that one does not cross into a container, so Frigate names the filesystem socket through `DXRT_DYNAMIC_IPC_ENDPOINT` on your behalf. Set that variable on the container yourself only if the daemon listens somewhere else, which means you also set it for `dxrtd` through its own systemd drop-in. The script writes `/etc/systemd/system/dxrt.service.d/frigate.conf` for exactly that, and has `dxrt.service` link the socket to `/tmp/dxrt_dynamic_ipc.sock` when it starts, so the host's own `dxrt-cli` and `dxtop` keep finding it at the default path they fall back to.
:::note
The DX-RT client exits when `dxrt.service` stops, so restart the Frigate container after restarting `dxrt.service`.
:::
The device node is needed as well as the socket, because the client opens the NPU directly even though the daemon arbitrates access. Without it, inference fails with `Device not found`.
`/dev/shm` does not need sharing.
The DX-RT python bindings are not shipped in the Frigate image. Frigate downloads them on first start when a DEEPX detector is configured, and caches them under `/config`.
#### Configuration
Finally, configure [hardware object detection](/configuration/object_detectors#deepx-npu) to complete the setup.
### Rockchip platform
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and necessary drivers (especially rkvdec2 and rknpu). To check, enter the following commands:
+1 -1
View File
@@ -184,6 +184,6 @@ Filters and masks only hide the incorrect result - they don't teach Frigate what
### Where do I see problems Frigate has detected?
Open System > Health. The Notices list keeps a record of problems Frigate has found, and you can dismiss any entry to acknowledge it. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has undismissed entries. On mobile, tap the warning icon in the bottom navigation bar to see them.
Open System > Health. The Notices list keeps a record of problems Frigate has found. Acknowledge an entry to hide it until the problem happens again, or mute it to hide it for good. Hidden entries stay listed under Show hidden in the filter. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has entries showing. On mobile, tap the warning icon in the bottom navigation bar to see them.
The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports.
+4 -4
View File
@@ -11375,15 +11375,15 @@
}
},
"node_modules/image-size": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.4.tgz",
"integrity": "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==",
"license": "MIT",
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
"node": ">=18"
}
},
"node_modules/immer": {
+82 -16
View File
@@ -4174,19 +4174,20 @@ paths:
Get notices, most severe first.
Args:
include_dismissed: Also return dismissed notices, for the history view
include_hidden: Also return acknowledged and muted notices, for the
hidden list
Returns:
The notices
operationId: get_notices_notices_get
parameters:
- name: include_dismissed
- name: include_hidden
in: query
required: false
schema:
type: boolean
default: false
title: Include Dismissed
title: Include Hidden
responses:
'200':
description: Successful Response
@@ -4221,16 +4222,16 @@ paths:
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/dismissed_checks:
/notices/muted_checks:
get:
tags:
- Notices
summary: Get Dismissed Checks
summary: Get Muted Checks
description: |-
**Access:** Admin role required.
Get the dismissed config and stream check rows, newest first.
operationId: get_dismissed_checks_notices_dismissed_checks_get
Get the muted config and stream check rows, newest first.
operationId: get_muted_checks_notices_muted_checks_get
responses:
'200':
description: Successful Response
@@ -4240,16 +4241,16 @@ paths:
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/dismissed:
/notices/hidden:
delete:
tags:
- Notices
summary: Purge Dismissed
summary: Unhide All Notices
description: |-
**Access:** Admin role required.
Delete every dismissed notice and check row so each can show again.
operationId: purge_dismissed_notices_dismissed_delete
Show every acknowledged and muted notice and check row again.
operationId: unhide_all_notices_notices_hidden_delete
responses:
'200':
description: Successful Response
@@ -4259,18 +4260,83 @@ paths:
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/{notice_id}/dismiss:
/notices/{notice_id}/acknowledge:
post:
tags:
- Notices
summary: Dismiss Notice
summary: Acknowledge Notice
description: |-
**Access:** Admin role required.
Hide a notice or a config or stream check row.
Hide a notice until it happens again.
It stays hidden if the same problem happens again.
operationId: dismiss_notice_notices__notice_id__dismiss_post
Config and stream check rows and the update notice never repeat, so they
can only be muted.
operationId: acknowledge_notice_notices__notice_id__acknowledge_post
parameters:
- name: notice_id
in: path
required: true
schema:
type: string
title: Notice Id
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/{notice_id}/mute:
post:
tags:
- Notices
summary: Mute Notice
description: |-
**Access:** Admin role required.
Hide a notice or a config or stream check row for good.
operationId: mute_notice_notices__notice_id__mute_post
parameters:
- name: notice_id
in: path
required: true
schema:
type: string
title: Notice Id
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/{notice_id}/hidden:
delete:
tags:
- Notices
summary: Unhide Notice
description: |-
**Access:** Admin role required.
Show an acknowledged or muted notice or check row again.
operationId: unhide_notice_notices__notice_id__hidden_delete
parameters:
- name: notice_id
in: path
+2
View File
@@ -429,6 +429,8 @@ def ffmpeg_presets():
hwaccel_presets = [
"preset-rpi-64-h264",
"preset-rpi-64-h265",
"preset-apple-silicon-h264",
"preset-apple-silicon-h265",
"preset-jetson-h264",
"preset-jetson-h265",
"preset-rkmpp",
+50 -22
View File
@@ -14,17 +14,18 @@ router = APIRouter(tags=[Tags.notices])
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse:
def get_notices(request: Request, include_hidden: bool = False) -> JSONResponse:
"""Get notices, most severe first.
Args:
include_dismissed: Also return dismissed notices, for the history view
include_hidden: Also return acknowledged and muted notices, for the
hidden list
Returns:
The notices
"""
return JSONResponse(
content=request.app.notice_registry.active(include_dismissed=include_dismissed)
content=request.app.notice_registry.active(include_hidden=include_hidden)
)
@@ -34,37 +35,64 @@ def get_notice_stats(request: Request) -> JSONResponse:
return JSONResponse(content=request.app.notice_registry.stats())
@router.get(
"/notices/dismissed_checks", dependencies=[Depends(require_role(["admin"]))]
)
def get_dismissed_checks(request: Request) -> JSONResponse:
"""Get the dismissed config and stream check rows, newest first."""
return JSONResponse(content=request.app.notice_registry.dismissed_checks())
@router.get("/notices/muted_checks", dependencies=[Depends(require_role(["admin"]))])
def get_muted_checks(request: Request) -> JSONResponse:
"""Get the muted config and stream check rows, newest first."""
return JSONResponse(content=request.app.notice_registry.muted_checks())
@router.delete("/notices/dismissed", dependencies=[Depends(require_role(["admin"]))])
def purge_dismissed(request: Request) -> JSONResponse:
"""Delete every dismissed notice and check row so each can show again."""
request.app.notice_registry.purge_dismissed()
return JSONResponse(
content={"success": True, "message": "Dismissed notices cleared"}
)
@router.delete("/notices/hidden", dependencies=[Depends(require_role(["admin"]))])
def unhide_all_notices(request: Request) -> JSONResponse:
"""Show every acknowledged and muted notice and check row again."""
request.app.notice_registry.unhide_all()
return JSONResponse(content={"success": True, "message": "Notices shown again"})
# model notice ids contain a slash, so the id is a path parameter
@router.post(
"/notices/{notice_id:path}/dismiss",
"/notices/{notice_id:path}/acknowledge",
dependencies=[Depends(require_role(["admin"]))],
)
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse:
"""Hide a notice or a config or stream check row.
def acknowledge_notice(request: Request, notice_id: str) -> JSONResponse:
"""Hide a notice until it happens again.
It stays hidden if the same problem happens again.
Config and stream check rows and the update notice never repeat, so they
can only be muted.
"""
if not request.app.notice_registry.dismiss(notice_id):
if not request.app.notice_registry.acknowledge(notice_id):
return JSONResponse(
content={"success": False, "message": "Notice not found"},
status_code=404,
)
return JSONResponse(content={"success": True, "message": "Notice dismissed"})
return JSONResponse(content={"success": True, "message": "Notice acknowledged"})
@router.post(
"/notices/{notice_id:path}/mute",
dependencies=[Depends(require_role(["admin"]))],
)
def mute_notice(request: Request, notice_id: str) -> JSONResponse:
"""Hide a notice or a config or stream check row for good."""
if not request.app.notice_registry.mute(notice_id):
return JSONResponse(
content={"success": False, "message": "Notice not found"},
status_code=404,
)
return JSONResponse(content={"success": True, "message": "Notice muted"})
@router.delete(
"/notices/{notice_id:path}/hidden",
dependencies=[Depends(require_role(["admin"]))],
)
def unhide_notice(request: Request, notice_id: str) -> JSONResponse:
"""Show an acknowledged or muted notice or check row again."""
if not request.app.notice_registry.unhide(notice_id):
return JSONResponse(
content={"success": False, "message": "Notice not found"},
status_code=404,
)
return JSONResponse(content={"success": True, "message": "Notice shown again"})
+51
View File
@@ -46,8 +46,42 @@ _PROVIDER_LABELS = {
"MIGraphXExecutionProvider": "MIGraphX",
"OpenVINOExecutionProvider": "OpenVINO",
"CPUExecutionProvider": "CPU",
"LighterANE": "Neural Engine",
}
# lighter (https://github.com/fieldwork-ai/lighter) places an ONNX Runtime plugin
# execution provider in a container started with --device lighter.sh/ane=all,
# which runs models on a Mac's Neural Engine; LIGHTER_ANE_EP names where it is
LIGHTER_ANE_EP_NAME = "LighterANE"
LIGHTER_ANE_LIBRARY = "/usr/lib/lighter/liblighter_ane_ep.so"
def get_lighter_ane_devices() -> list[Any]:
"""Get the Neural Engine devices lighter's provider offers, registering it once.
Returns:
The provider's ONNX Runtime devices, or an empty list without lighter's device
"""
library = os.environ.get("LIGHTER_ANE_EP", LIGHTER_ANE_LIBRARY)
if not os.path.exists(library):
return []
devices = [d for d in ort.get_ep_devices() if d.ep_name == LIGHTER_ANE_EP_NAME]
if not devices:
try:
ort.register_execution_provider_library(LIGHTER_ANE_EP_NAME, library)
except Exception as e:
logger.warning(
f"Failed to load the Neural Engine provider from {library}: {e}"
)
return []
devices = [d for d in ort.get_ep_devices() if d.ep_name == LIGHTER_ANE_EP_NAME]
return devices
def is_arm64_platform() -> bool:
"""Check if we're running on an ARM platform."""
@@ -689,6 +723,23 @@ def get_optimized_runner(
if rknn_path:
return _record_runner(model_path, model_type, RKNNModelRunner(rknn_path))
if device != "CPU" and (ane_devices := get_lighter_ane_devices()):
sess_options = get_ort_session_options(model_type) or ort.SessionOptions()
sess_options.add_provider_for_devices(ane_devices, {})
try:
session = ort.InferenceSession(model_path, sess_options=sess_options)
except Exception as e:
logger.warning(
f"Failed to load {model_path} on the Neural Engine, using the default providers: {e}"
)
else:
return _record_runner(
model_path,
model_type,
ONNXModelRunner(session, model_type=model_type),
)
providers, options = get_ort_providers(device == "CPU", device, **kwargs)
if providers[0] == "CPUExecutionProvider":
+26
View File
@@ -25,6 +25,7 @@ SYS_ROOT = "/sys"
DEV_ROOT = "/dev"
PROC_ROOT = "/proc"
ETC_ROOT = "/etc"
LIB_ROOT = "/usr/lib"
# a Coral reports as Global Unichip until its firmware is loaded, then as Google
CORAL_USB_IDS = {("1a6e", "089a"), ("18d1", "9302")}
@@ -273,6 +274,16 @@ def detect_memryx() -> DetectionHardware | None:
return _hardware("memryx", "memryx", "MemryX MX3", units)
def detect_deepx() -> DetectionHardware | None:
"""Find DEEPX NPUs by their device nodes."""
units = _dev_units("dxrt*", "deepx:PCIe:{index}", "PCIe")
if not units:
return None
return _hardware("deepx", "deepx", "DEEPX NPU", units)
def detect_rockchip() -> DetectionHardware | None:
"""Find a Rockchip NPU by reading the SoC from the device tree."""
compatible = _read(f"{PROC_ROOT}/device-tree/compatible")
@@ -307,6 +318,19 @@ def detect_synaptics() -> DetectionHardware | None:
return _hardware("synaptics", "synaptics", "Synaptics NPU", units)
def detect_lighter_ane() -> DetectionHardware | None:
"""Find a Mac's Neural Engine by the provider library lighter's device places."""
library = os.environ.get(
"LIGHTER_ANE_EP", f"{LIB_ROOT}/lighter/liblighter_ane_ep.so"
)
if not os.path.exists(library):
return None
# runs through onnx, whose session picks lighter's provider when it is present
units = [HardwareUnit(device="onnx", label="Neural Engine")]
return _hardware("onnx:lighter", "onnx", "Apple Neural Engine", units)
def detect_cpu() -> DetectionHardware:
"""The CPU, which is always available."""
units = [HardwareUnit(device="cpu", label="CPU")]
@@ -319,6 +343,7 @@ PROBES = (
detect_coral_usb,
detect_hailo,
detect_memryx,
detect_deepx,
detect_intel_npu,
detect_intel_gpu,
detect_nvidia_gpu,
@@ -327,6 +352,7 @@ PROBES = (
detect_rockchip,
detect_axengine,
detect_synaptics,
detect_lighter_ane,
detect_cpu,
)
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,7 +1,7 @@
import json
import logging
import os
from typing import Any, Literal
from typing import Any, ClassVar, Literal
import numpy as np
import zmq
@@ -21,6 +21,7 @@ class ZmqDetectorConfig(BaseDetectorConfig):
model_config = ConfigDict(
title="ZMQ IPC",
)
device_spec_field: ClassVar[str] = "endpoint"
type: Literal[DETECTOR_KEY]
endpoint: str = Field(
+9
View File
@@ -84,6 +84,8 @@ _user_agent_args = [
PRESETS_HW_ACCEL_DECODE = {
"preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m",
"preset-rpi-64-h265": "-c:v:1 hevc_v4l2m2m",
"preset-apple-silicon-h264": "-c:v h264_v4l2m2m",
"preset-apple-silicon-h265": "-c:v hevc_v4l2m2m",
FFMPEG_HWACCEL_VAAPI: "-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {3} -hwaccel_output_format vaapi",
"preset-intel-qsv-h264": f"-hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
"preset-intel-qsv-h265": f"-load_plugin hevc_hw -hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
@@ -120,6 +122,9 @@ PRESETS_HW_ACCEL_DECODE["preset-rk-h265"] = PRESETS_HW_ACCEL_DECODE[
PRESETS_HW_ACCEL_SCALE = {
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}",
# ffmpeg's v4l2m2m decoders cannot scale, so frames are scaled on the CPU
"preset-apple-silicon-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-apple-silicon-h265": "-r {0} -vf fps={0},scale={1}:{2}",
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
@@ -150,6 +155,8 @@ PRESETS_HW_ACCEL_SCALE["preset-rk-h265"] = PRESETS_HW_ACCEL_SCALE[FFMPEG_HWACCEL
PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = {
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}",
"preset-apple-silicon-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
"preset-apple-silicon-h265": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
# -vaapi_device is required in addition to -hwaccel_device: this is the only
# birdseye preset that uses hwupload, and ffmpeg 8 initializes filters before
# the decoder creates a device, so hwupload cannot see an -hwaccel_device one.
@@ -184,6 +191,8 @@ PRESETS_HW_ACCEL_ENCODE_BIRDSEYE["preset-rk-h264"] = PRESETS_HW_ACCEL_ENCODE_BIR
PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = {
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}",
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}",
"preset-apple-silicon-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}",
"preset-apple-silicon-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}",
FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi {2}",
"preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {2}",
"preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v hevc_qsv -profile:v main -level:v 4.1 -async_depth:v 1 {2}",
+8 -3
View File
@@ -195,15 +195,20 @@ class Notice(Model):
first_seen = DateTimeField()
last_seen = DateTimeField()
count = IntegerField(default=1)
dismissed_at = DateTimeField(null=True)
# hidden until the next occurrence
acknowledged_at = DateTimeField(null=True)
# hidden for good
muted_at = DateTimeField(null=True)
class NoticeStats(Model):
kind = CharField(null=False, primary_key=True, max_length=50)
occurrences = IntegerField(default=0)
dismissals = IntegerField(default=0)
acknowledgements = IntegerField(default=0)
mutes = IntegerField(default=0)
first_seen = DateTimeField()
last_seen = DateTimeField()
# watermarks for a future analytics reporter; unused until then
reported_occurrences = IntegerField(default=0)
reported_dismissals = IntegerField(default=0)
reported_acknowledgements = IntegerField(default=0)
reported_mutes = IntegerField(default=0)
+110 -43
View File
@@ -5,7 +5,7 @@ import threading
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from typing import Any, cast
from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import Notice, NoticeStats
@@ -77,9 +77,8 @@ class NoticeRegistry:
) -> None:
"""Insert a notice or count another occurrence of it.
A dismissed notice stays dismissed when it is raised again, unless its
kind sets reopen_at_count. A kind that should come back after a
dismissal gives each episode its own scope.
Another occurrence shows an acknowledged notice again. A muted notice
stays hidden.
"""
definition = NOTICE_KINDS.get(kind)
@@ -112,7 +111,6 @@ class NoticeRegistry:
first_seen=now,
last_seen=now,
count=1,
dismissed_at=None,
)
self._bump_occurrences(kind, 1, now)
@@ -175,7 +173,7 @@ class NoticeRegistry:
self._notify()
def resolve_camera(self, camera: str) -> None:
"""Drop the notices and check dismissals of a camera being deleted."""
"""Drop the notices and check mutes of a camera being deleted."""
camera_kinds = [
key
for key, definition in NOTICE_KINDS.items()
@@ -193,7 +191,7 @@ class NoticeRegistry:
)
# a stream id names its camera first; a config id ends with camera.<name>
for check in self.dismissed_checks():
for check in self.muted_checks():
check_id = check["id"]
if check_id.startswith(f"stream:{camera}:") or (
@@ -205,26 +203,50 @@ class NoticeRegistry:
if deleted:
self._notify()
def purge_dismissed(self) -> int:
"""Delete every dismissed row so each can show again. Returns how many."""
with self._lock:
return int(
Notice.delete().where(Notice.dismissed_at.is_null(False)).execute()
)
def acknowledge(self, row_id: str) -> bool:
"""Hide a notice until it happens again.
def dismiss(self, row_id: str) -> bool:
"""Hide a notice or check row for good. Returns False for an unknown id."""
Returns False for an unknown id or a kind that never repeats, such as
a check row or the update notice.
"""
with self._lock:
existing = Notice.get_or_none(Notice.id == row_id)
if existing is None:
# a check row gets a notice row only once it is dismissed
return False
definition = NOTICE_KINDS.get(existing.kind)
if definition is None or not definition.counts_repeats:
return False
if existing.acknowledged_at is not None or existing.muted_at is not None:
return True
Notice.update(acknowledged_at=datetime.now().timestamp()).where(
Notice.id == row_id
).execute()
NoticeStats.update(acknowledgements=NoticeStats.acknowledgements + 1).where(
NoticeStats.kind == existing.kind
).execute()
self._notify()
return True
def mute(self, row_id: str) -> bool:
"""Hide a notice or check row for good. Returns False for an unknown id."""
now = datetime.now().timestamp()
with self._lock:
existing = Notice.get_or_none(Notice.id == row_id)
if existing is None:
# a check row gets a notice row only once it is muted
kind, _, scope = row_id.partition(":")
if kind not in CHECK_KINDS or not scope:
return False
now = datetime.now().timestamp()
Notice.create(
id=row_id,
kind=kind,
@@ -233,40 +255,78 @@ class NoticeRegistry:
first_seen=now,
last_seen=now,
count=1,
dismissed_at=now,
muted_at=now,
)
return True
if existing.dismissed_at is not None:
if existing.muted_at is not None:
return True
if existing.kind not in NOTICE_KINDS:
return False
Notice.update(dismissed_at=datetime.now().timestamp()).where(
Notice.update(acknowledged_at=None, muted_at=now).where(
Notice.id == row_id
).execute()
NoticeStats.update(dismissals=NoticeStats.dismissals + 1).where(
NoticeStats.update(mutes=NoticeStats.mutes + 1).where(
NoticeStats.kind == existing.kind
).execute()
self._notify()
return True
def dismissed_checks(self) -> list[dict[str, Any]]:
"""Dismissed config and stream check rows, newest first."""
def unhide(self, row_id: str) -> bool:
"""Show an acknowledged or muted row again. Returns False for an unknown id."""
with self._lock:
existing = Notice.get_or_none(Notice.id == row_id)
if existing is None:
return False
if existing.kind in CHECK_KINDS:
Notice.delete_by_id(row_id)
return True
Notice.update(acknowledged_at=None, muted_at=None).where(
Notice.id == row_id
).execute()
self._notify()
return True
def unhide_all(self) -> None:
"""Show every acknowledged and muted row again."""
with self._lock:
for check in self.muted_checks():
Notice.delete_by_id(check["id"])
shown = (
Notice.update(acknowledged_at=None, muted_at=None)
.where(
Notice.acknowledged_at.is_null(False)
| Notice.muted_at.is_null(False)
)
.execute()
)
if shown:
self._notify()
def muted_checks(self) -> list[dict[str, Any]]:
"""Muted config and stream check rows, newest first."""
rows = (
Notice.select()
.where(Notice.kind.in_(list(CHECK_KINDS)))
.order_by(Notice.dismissed_at.desc())
.order_by(Notice.muted_at.desc())
)
return [{"id": row.id, "dismissed_at": row.dismissed_at} for row in rows]
return [{"id": row.id, "muted_at": row.muted_at} for row in rows]
def active(self, include_dismissed: bool = False) -> list[dict[str, Any]]:
def active(self, include_hidden: bool = False) -> list[dict[str, Any]]:
"""Notices most severe first, then most recent first.
Args:
include_dismissed: Also return dismissed notices, for the history view
include_hidden: Also return acknowledged and muted notices, for the
hidden list
"""
rows = []
@@ -276,7 +336,9 @@ class NoticeRegistry:
if definition is None:
continue
if row.dismissed_at is not None and not include_dismissed:
hidden = row.acknowledged_at is not None or row.muted_at is not None
if hidden and not include_hidden:
continue
rows.append(
@@ -291,7 +353,9 @@ class NoticeRegistry:
"first_seen": row.first_seen,
"last_seen": row.last_seen,
"count": row.count,
"dismissed_at": row.dismissed_at,
"acknowledgeable": definition.counts_repeats,
"acknowledged_at": row.acknowledged_at,
"muted_at": row.muted_at,
}
)
@@ -309,11 +373,13 @@ class NoticeRegistry:
{
"kind": row.kind,
"occurrences": row.occurrences,
"dismissals": row.dismissals,
"acknowledgements": row.acknowledgements,
"mutes": row.mutes,
"first_seen": row.first_seen,
"last_seen": row.last_seen,
"reported_occurrences": row.reported_occurrences,
"reported_dismissals": row.reported_dismissals,
"reported_acknowledgements": row.reported_acknowledgements,
"reported_mutes": row.reported_mutes,
}
for row in NoticeStats.select()
if row.kind in NOTICE_KINDS
@@ -327,18 +393,18 @@ class NoticeRegistry:
last_seen: float,
params: dict[str, Any],
) -> None:
# called with the lock held
fields: dict[str, Any] = {
"count": row.count + count,
"last_seen": last_seen,
"params": params,
}
reopen_at = NOTICE_KINDS[kind].reopen_at_count
# called with the lock held; held repeats from before an acknowledgement
# still count but leave the notice hidden
still_acknowledged = row.acknowledged_at is not None and (
last_seen <= cast(float, row.acknowledged_at)
)
if reopen_at is not None and row.count < reopen_at <= row.count + count:
fields["dismissed_at"] = None
Notice.update(**fields).where(Notice.id == row.id).execute()
Notice.update(
count=row.count + count,
last_seen=last_seen,
params=params,
acknowledged_at=row.acknowledged_at if still_acknowledged else None,
).where(Notice.id == row.id).execute()
self._bump_occurrences(kind, count, last_seen)
def _prune(self, kind: str, keep: int) -> None:
@@ -362,7 +428,8 @@ class NoticeRegistry:
NoticeStats.create(
kind=kind,
occurrences=count,
dismissals=0,
acknowledgements=0,
mutes=0,
first_seen=now,
last_seen=now,
)
+10 -6
View File
@@ -28,9 +28,9 @@ class NoticeKind:
category: camera, detector, model, or system; a camera scope is a
camera name, and the UI shows it
link: app route or absolute URL for the row, filled in from params
counts_repeats: whether raising an existing notice counts another occurrence
counts_repeats: whether raising an existing notice counts another
occurrence, which also shows an acknowledged notice again
batch_repeats: whether repeats wait in memory for the next flush
reopen_at_count: count at which a dismissed notice shows again
keep_latest: rows of this kind to keep; a new row drops the oldest
reportable: whether a future analytics reporter may send this kind's counts
"""
@@ -41,7 +41,6 @@ class NoticeKind:
link: str | None = None
counts_repeats: bool = True
batch_repeats: bool = False
reopen_at_count: int | None = None
keep_latest: int | None = None
reportable: bool = True
@@ -67,6 +66,12 @@ _KINDS = (
"camera",
link="/system#cameras",
),
NoticeKind(
"ffmpeg_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
),
NoticeKind(
"detect_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
),
NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"),
# one row per user per burst; the login log lines carry the address
NoticeKind(
@@ -75,10 +80,9 @@ _KINDS = (
"system",
link="/logs",
batch_repeats=True,
reopen_at_count=5,
keep_latest=100,
),
# one row per release, so a dismissal lasts until the next release
# one row per release, so muting it lasts until the next release
NoticeKind(
"update_available",
NoticeSeverity.info,
@@ -92,7 +96,7 @@ _KINDS = (
NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS}
# the Health tab builds config and stream check rows in the browser, so a notice
# row of these kinds only records a dismissal; its other fields are placeholders
# row of these kinds only records a mute; its other fields are placeholders
CHECK_KINDS = frozenset({"config", "stream"})
+61 -15
View File
@@ -29,38 +29,59 @@ VERSION_REFRESH_S = 24 * 60 * 60
# a camera skipping at least this percent of its frames is falling behind
SKIPPED_DETECTIONS_PCT = 5
# for at least this long before it becomes a notice
SKIPPED_DETECTIONS_HOLD_S = 60
# lifetime CPU averages at or above these are high; they match
# CameraFfmpegThreshold.error and CameraDetectThreshold.error in the UI
FFMPEG_HIGH_CPU_PCT = 20
DETECT_HIGH_CPU_PCT = 40
# a camera stays over a threshold this long before it becomes a notice
EPISODE_HOLD_S = 60
# detectors warm up after a start; the status bar waits this long too
STARTUP_GRACE_S = 120
class SkippedDetectionsTracker:
"""Finds cameras whose skipped share stays high long enough for a notice."""
def cpu_average(cpu_usages: dict[str, Any], pid: int | None) -> float | None:
"""A process's CPU use averaged over its lifetime, or None if unknown."""
usage = cpu_usages.get(str(pid)) if pid else None
def __init__(self) -> None:
try:
return float(usage["cpu_average"]) if usage else None
except (KeyError, TypeError, ValueError):
return None
class EpisodeTracker:
"""Finds cameras whose value stays over a threshold long enough for a notice."""
def __init__(self, threshold: float) -> None:
self._threshold = threshold
self._since: dict[str, float] = {}
self._raised: set[str] = set()
def update(self, cameras: dict[str, dict[str, Any]], now: float) -> list[str]:
"""Return the cameras whose episode qualified on this sample."""
def update(self, values: dict[str, float | None], now: float) -> list[str]:
"""Return the cameras whose episode qualified on this sample.
Args:
values: Each camera's current value, or None when it has none
now: Sample time in seconds
"""
qualified: list[str] = []
# a removed camera that comes back starts a new episode
for camera in self._since.keys() - cameras.keys():
for camera in self._since.keys() - values.keys():
self._since.pop(camera)
self._raised.discard(camera)
for camera, camera_stats in cameras.items():
if camera_stats["skipped_pct"] < SKIPPED_DETECTIONS_PCT:
for camera, value in values.items():
if value is None or value < self._threshold:
self._since.pop(camera, None)
self._raised.discard(camera)
continue
since = self._since.setdefault(camera, now)
if camera not in self._raised and now - since >= SKIPPED_DETECTIONS_HOLD_S:
if camera not in self._raised and now - since >= EPISODE_HOLD_S:
self._raised.add(camera)
qualified.append(camera)
@@ -80,7 +101,9 @@ class StatsEmitter(threading.Thread):
self.stop_event = stop_event
self.hardware_stats = HardwareStats(config)
self.stats_history: list[dict[str, Any]] = []
self.skipped_detections = SkippedDetectionsTracker()
self.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
self.ffmpeg_cpu = EpisodeTracker(FFMPEG_HIGH_CPU_PCT)
self.detect_cpu = EpisodeTracker(DETECT_HIGH_CPU_PCT)
# the shm notice's params as last sent, so only a change is written
self._shm_checked = False
@@ -227,15 +250,38 @@ class StatsEmitter(threading.Thread):
def _update_notices(self, stats: dict[str, Any], now: float) -> None:
"""Update notices based on current stats or time."""
# skipped detections
cameras = stats["cameras"]
# absent when CPU collection timed out or failed on this tick
cpu_usages = stats.get("cpu_usages", {})
if stats["service"]["uptime"] >= STARTUP_GRACE_S:
for camera in self.skipped_detections.update(stats["cameras"], now):
# skipped detections
skipped = {
camera: camera_stats["skipped_pct"]
for camera, camera_stats in cameras.items()
}
for camera in self.skipped_detections.update(skipped, now):
raise_notice(
"skipped_detections",
scope=camera,
params={"pct": stats["cameras"][camera]["skipped_pct"]},
params={"pct": skipped[camera]},
)
# high ffmpeg and detect CPU
for tracker, kind, pid_key in (
(self.ffmpeg_cpu, "ffmpeg_high_cpu", "ffmpeg_pid"),
(self.detect_cpu, "detect_high_cpu", "pid"),
):
averages = {
camera: cpu_average(cpu_usages, camera_stats.get(pid_key))
for camera, camera_stats in cameras.items()
}
for camera in tracker.update(averages, now):
raise_notice(kind, scope=camera, params={"cpu": averages[camera]})
# shm too small for the cameras
self._update_shm_notice(stats["service"]["storage"]["/dev/shm"])
+61 -45
View File
@@ -53,92 +53,108 @@ class TestHttpNotices(BaseTestHttp):
self.assertEqual(response.json()[0]["kind"], "detector_stuck")
self.assertEqual(response.json()[0]["occurrences"], 1)
def test_dismiss_hides_and_history_keeps_it(self):
def test_acknowledge_hides_and_the_hidden_list_keeps_it(self):
self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"}
)
with self.client() as client:
response = client.post("/notices/detector_stuck:ov/dismiss")
response = client.post("/notices/detector_stuck:ov/acknowledge")
listed = client.get("/notices").json()
history = client.get("/notices", params={"include_dismissed": True}).json()
hidden = client.get("/notices", params={"include_hidden": True}).json()
self.assertEqual(response.status_code, 200)
self.assertEqual(listed, [])
self.assertEqual([n["id"] for n in history], ["detector_stuck:ov"])
self.assertIsNotNone(history[0]["dismissed_at"])
self.assertEqual([n["id"] for n in hidden], ["detector_stuck:ov"])
self.assertIsNotNone(hidden[0]["acknowledged_at"])
def test_dismiss_an_id_with_a_slash(self):
def test_acknowledging_a_check_is_404(self):
with self.client() as client:
response = client.post(f"/notices/{CONFIG_CHECK}/acknowledge")
self.assertEqual(response.status_code, 404)
def test_mute_an_id_with_a_slash(self):
self.registry.raise_notice(
"model_download_failed", scope="yolo/model.onnx", params={}
)
with self.client() as client:
response = client.post(
"/notices/model_download_failed:yolo/model.onnx/dismiss"
"/notices/model_download_failed:yolo/model.onnx/mute"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.registry.active(), [])
def test_dismiss_unknown_is_404(self):
def test_unknown_ids_are_404(self):
with self.client() as client:
response = client.post("/notices/nope/dismiss")
responses = [
client.post("/notices/nope/acknowledge"),
client.post("/notices/nope/mute"),
client.delete("/notices/nope/hidden"),
]
self.assertEqual(response.status_code, 404)
self.assertEqual([r.status_code for r in responses], [404, 404, 404])
def test_requires_admin(self):
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
with TestClient(self.app) as client:
response = client.get(
"/notices", headers={"remote-user": "viewer", "remote-role": "viewer"}
)
responses = [
client.get("/notices", headers=viewer),
client.get("/notices/muted_checks", headers=viewer),
client.post("/notices/detector_stuck:ov/acknowledge", headers=viewer),
client.post("/notices/detector_stuck:ov/mute", headers=viewer),
client.delete("/notices/detector_stuck:ov/hidden", headers=viewer),
client.delete("/notices/hidden", headers=viewer),
]
self.assertEqual(response.status_code, 403)
self.assertEqual([r.status_code for r in responses], [403] * 6)
def test_dismissed_checks_are_listed_once(self):
def test_muted_checks_are_listed_once(self):
with self.client() as client:
first = client.post(f"/notices/{CONFIG_CHECK}/dismiss")
again = client.post(f"/notices/{CONFIG_CHECK}/dismiss")
listed = client.get("/notices/dismissed_checks").json()
history = client.get("/notices", params={"include_dismissed": True}).json()
first = client.post(f"/notices/{CONFIG_CHECK}/mute")
again = client.post(f"/notices/{CONFIG_CHECK}/mute")
listed = client.get("/notices/muted_checks").json()
hidden = client.get("/notices", params={"include_hidden": True}).json()
self.assertEqual(first.status_code, 200)
self.assertEqual(again.status_code, 200)
self.assertEqual([check["id"] for check in listed], [CONFIG_CHECK])
self.assertIsNotNone(listed[0]["dismissed_at"])
self.assertIsNotNone(listed[0]["muted_at"])
# the Health tab builds the row itself, so it never comes back as a notice
self.assertEqual(history, [])
self.assertEqual(hidden, [])
def test_dismissed_checks_require_admin(self):
with TestClient(self.app) as client:
response = client.get(
"/notices/dismissed_checks",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
self.assertEqual(response.status_code, 403)
def test_purge_dismissed_clears_notices_and_checks(self):
def test_unhide_one_row(self):
self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"}
)
self.registry.dismiss("detector_stuck:ov")
self.registry.dismiss(CONFIG_CHECK)
self.registry.mute("detector_stuck:ov")
self.registry.mute(CONFIG_CHECK)
with self.client() as client:
response = client.delete("/notices/dismissed")
history = client.get("/notices", params={"include_dismissed": True}).json()
checks = client.get("/notices/dismissed_checks").json()
notice = client.delete("/notices/detector_stuck:ov/hidden")
check = client.delete(f"/notices/{CONFIG_CHECK}/hidden")
listed = client.get("/notices").json()
checks = client.get("/notices/muted_checks").json()
self.assertEqual(response.status_code, 200)
self.assertEqual(history, [])
self.assertEqual([notice.status_code, check.status_code], [200, 200])
self.assertEqual([n["id"] for n in listed], ["detector_stuck:ov"])
self.assertEqual(checks, [])
def test_purge_dismissed_requires_admin(self):
with TestClient(self.app) as client:
response = client.delete(
"/notices/dismissed",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
def test_unhide_all_shows_notices_and_drops_check_mutes(self):
self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"}
)
self.registry.acknowledge("detector_stuck:ov")
self.registry.mute(CONFIG_CHECK)
self.assertEqual(response.status_code, 403)
with self.client() as client:
response = client.delete("/notices/hidden")
listed = client.get("/notices").json()
checks = client.get("/notices/muted_checks").json()
self.assertEqual(response.status_code, 200)
self.assertEqual([n["id"] for n in listed], ["detector_stuck:ov"])
self.assertEqual(checks, [])
+25
View File
@@ -36,6 +36,31 @@ class TestEventsPerSecond(unittest.TestCase):
clock[0] += 100.0
self.assertEqual(eps.eps(), 0.0)
def test_burst_after_start_is_not_divided_by_a_tiny_window(self) -> None:
eps = EventsPerSecond(last_n_seconds=10)
clock = [1000.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
# eleven buffered frames arrive within 100 ms of starting
for _ in range(11):
clock[0] += 0.01
eps.update()
# 11 events over less than a second is at most 11 per second
self.assertLessEqual(eps.eps(), 11.0)
def test_subsecond_window_keeps_its_rate(self) -> None:
eps = EventsPerSecond(last_n_seconds=0.5)
clock = [1000.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
# twenty events per second for two seconds
for _ in range(40):
clock[0] += 0.05
eps.update()
# read between events, so none sits exactly on the window edge
clock[0] += 0.01
self.assertAlmostEqual(eps.eps(), 20.0)
if __name__ == "__main__":
unittest.main()
+896
View File
@@ -0,0 +1,896 @@
"""Tests for the DEEPX detector."""
import json
import os
import struct
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import numpy as np
from pydantic import ValidationError
from frigate.detectors.detector_config import ModelConfig, ModelTypeEnum
from frigate.detectors.device import (
DeviceParseError,
build_detector_config,
parse_device,
)
from frigate.detectors.plugins.deepx import (
DEEPX_MANIFEST,
DXRT_VERSION,
PPU_RECORD_SIZE,
DeepxDetector,
DeepxDetectorConfig,
PpuLayout,
YoloLayout,
class_count,
decode_raw_anchor,
decode_raw_nms_in_head,
infer_yolo_layout,
read_ppu_layout,
resolve_device,
validate_yolox_outputs,
)
def model_with_type(model_type) -> ModelConfig:
return ModelConfig(
model_type=model_type,
labelmap_path=None,
labelmap={79: "toothbrush"},
width=640,
height=640,
)
def build_ppu_record(box, score=0.9, label=0, grid=(7, 9, 2, 2)) -> np.ndarray:
record = np.zeros(PPU_RECORD_SIZE, dtype=np.uint8)
record[0:16] = np.array(box, dtype=np.float32).view(np.uint8)
record[16:20] = grid
record[20:24] = np.array([score], dtype=np.float32).view(np.uint8)
record[24:28] = np.array([label], dtype=np.uint32).view(np.uint8)
return record.reshape(1, 1, PPU_RECORD_SIZE)
# compile_config.ppu as DX-COM writes it for the two head kinds
ANCHOR_BASED_PPU = {"type": 0, "num_classes": 80, "activation": "Sigmoid"}
ANCHOR_FREE_PPU = {"type": 1, "num_classes": 80}
PPU_BBOX_NODE = "/head/Mul_2"
# (grid_w, grid_h, entries) per scale, finest first; the grids give the
# strides at a 640 input
THREE_SCALE_ANCHORS = [(80, 80, 3), (40, 40, 3), (20, 20, 3)]
TWO_SCALE_ANCHORS = [(40, 40, 3), (20, 20, 3)]
FOUR_SCALE_ANCHORS = [(160, 160, 3), (80, 80, 3), (40, 40, 3), (20, 20, 3)]
THREE_SCALE_FREE = [(80, 80, 1), (40, 40, 1), (20, 20, 1)]
ONE_SCALE_FREE = [(100, 84, 1)]
def proto_varint(value: int) -> bytes:
out = bytearray()
while True:
byte = value & 0x7F
value >>= 7
out.append(byte | (0x80 if value else 0))
if not value:
return bytes(out)
def proto_bytes(field: int, payload: bytes) -> bytes:
return proto_varint(field << 3 | 2) + proto_varint(len(payload)) + payload
def proto_number(field: int, value: int) -> bytes:
return proto_varint(field << 3) + proto_varint(value)
def onnx_node(op_type: str, name: str, inputs: list, outputs: list) -> bytes:
body = b"".join(proto_bytes(1, tensor.encode()) for tensor in inputs)
body += b"".join(proto_bytes(2, tensor.encode()) for tensor in outputs)
return body + proto_bytes(3, name.encode()) + proto_bytes(4, op_type.encode())
def onnx_box_graph(box_format: str) -> bytes:
if box_format == "broken":
return proto_varint(1 << 3 | 3)
unnamed = box_format == "unnamed"
def graph_node(op_type: str, name: str, inputs: list, outputs: list) -> bytes:
return onnx_node(op_type, "" if unnamed else name, inputs, outputs)
nodes = [
graph_node("Split", "dfl_split", ["dfl", "sizes"], ["lt", "rb"]),
graph_node("Sub", "corner_min", ["anchors", "lt"], ["x1y1"]),
graph_node("Add", "corner_max", ["rb", "anchors"], ["x2y2"]),
]
if box_format in ("centre", "unnamed"):
nodes += [
graph_node("Add", "corner_sum", ["x1y1", "x2y2"], ["sum"]),
graph_node("Mul", "corner_mean", ["sum", "half"], ["cxy"]),
graph_node("Sub", "corner_span", ["x2y2", "x1y1"], ["wh"]),
graph_node("Concat", "box_concat", ["cxy", "wh"], ["box"]),
]
elif box_format == "corner":
nodes.append(graph_node("Concat", "box_concat", ["x1y1", "x2y2"], ["box"]))
else:
nodes.append(graph_node("Concat", "box_concat", ["x1y1", "x1y1"], ["box"]))
box = "box"
if unnamed:
box = "box_flat"
nodes.append(graph_node("Reshape", "box_reshape", ["shape", "box"], [box]))
nodes.append(onnx_node("Mul", PPU_BBOX_NODE, [box, "strides"], ["bbox_out"]))
graph = b"".join(proto_bytes(1, node) for node in nodes)
graph += proto_bytes(5, b"weights")
return (
proto_number(1, 10) # ir_version
+ proto_bytes(2, b"onnx_frontend_compiler") # producer_name
+ proto_bytes(7, graph)
)
def write_dxnn(
directory, ppu, layers, name="model.dxnn", table=True, box_format=None
) -> str:
"""A minimal .dxnn as DX-RT's parsers read it: the container header, a
compile_config carrying `ppu`, and either the PPU tensor table with one
entry per (layer, anchor) as a v8 file has, or with `table` False only
the rmap_info listing of the PPU output tensors, as a v7 file has.
`layers` is (grid_w, grid_h, entries) per scale, finest first; an
anchor-free scale has one entry, and grid_h 1 means a flattened
(1, cells, channels) tensor. `box_format`, "centre" or "corner", adds
the compiled graph that says how the head writes its boxes, along with
the compile_config layer naming the node the PPU reads them from."""
if box_format is not None and "layer" not in ppu:
ppu = dict(ppu, layer=[{"bbox": PPU_BBOX_NODE, "cls_conf": "/head/Sigmoid"}])
compile_config = json.dumps({"compile_version": "2.4.0", "ppu": ppu}).encode()
graph = onnx_box_graph(box_format) if box_format is not None else b""
if table:
part = bytearray(struct.pack("<BBBB", 1, sum(n for _, _, n in layers), 0, 0))
for conv, (grid_w, grid_h, entries) in enumerate(layers):
for anchor in range(entries):
part += struct.pack(
"<HHfBBBBBBBB",
128,
80,
0.001,
conv,
anchor,
0,
1,
0,
grid_w,
grid_h,
0,
)
else:
outputs = []
for conv, (grid_w, grid_h, entries) in enumerate(layers):
for anchor in range(entries):
name_ = f"PPU_Transpose_Output_{conv}"
if entries > 1:
name_ += f"_anchor_{anchor}"
shape = [1, grid_w, 127] if grid_h == 1 else [1, grid_h, grid_w, 128]
outputs.append({"name": name_, "shape": shape, "layout": "PPU_YOLO"})
part = json.dumps({"inputs": [], "outputs": outputs}).encode()
data = {
"compile_config": {
"type": "str",
"offset": 0,
"size": len(compile_config),
},
"compiled_data": {
"M1A_4K": {
"npu_0": {
"rmap": {"type": "bytes", "offset": 0, "size": 0},
"ppu" if table else "rmap_info": {
"type": "bytes" if table else "str",
"offset": len(compile_config),
"size": len(part),
},
}
}
},
}
if graph:
data["vis_npu_models"] = {
"npu_0": {
"type": "bytes",
"offset": len(compile_config) + len(part),
"size": len(graph),
}
}
index = json.dumps(
{
"version": 8 if table else 7,
"signature": "DXNN",
"size": 8192,
"data": data,
}
).encode()
path = os.path.join(directory, name)
with open(path, "wb") as model:
model.write(b"DXNN" + struct.pack("<I", 8))
model.write(index.ljust(8192 - 8, b"\0"))
model.write(compile_config + part + graph)
return path
def layout_of(shapes, num_classes, ppu=False, dynamic_output=False) -> YoloLayout:
return infer_yolo_layout(shapes, num_classes, ppu, dynamic_output).layout
class TestDeepxModelFile(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
def layout(self, ppu, layers, **kwargs) -> PpuLayout | None:
return read_ppu_layout(write_dxnn(self.tmp.name, ppu, layers, **kwargs))
def test_the_head_kind_and_one_grid_per_scale_are_read(self):
cases = {
"anchor-based: three anchors per scale collapse to one grid each": (
(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS, {}),
PpuLayout(anchor_based=True, grids=((80, 80), (40, 40), (20, 20))),
),
"anchor-free, one scale flattened into a single tensor": (
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, {"box_format": "centre"}),
PpuLayout(anchor_based=False, grids=((100, 84),), centre_boxes=True),
),
"a head kind the compiler does not name still yields the scales": (
({"type": 7}, [(80, 80, 3), (40, 40, 3)], {}),
PpuLayout(anchor_based=None, grids=((80, 80), (40, 40))),
),
"no table: the per-anchor split says anchor-based": (
({"num_classes": 80}, THREE_SCALE_ANCHORS, {"table": False}),
PpuLayout(anchor_based=True, grids=((80, 80), (40, 40), (20, 20))),
),
"no table: compile_config places the scales ahead of the names": (
(
{
"type": 1,
"outputs": {
f"PPU_Transpose_Output_{i}": {"conv_idx": 2 - i}
for i in range(3)
},
},
[(20, 20, 1), (40, 40, 1), (80, 80, 1)],
{"table": False},
),
PpuLayout(anchor_based=False, grids=((80, 80), (40, 40), (20, 20))),
),
"no table: every scale in one (1, cells, channels) tensor": (
(ANCHOR_FREE_PPU, [(8400, 1, 1)], {"table": False}),
PpuLayout(anchor_based=False, grids=((8400, 1),)),
),
}
for head, ((ppu, layers, kwargs), expected) in cases.items():
with self.subTest(head=head):
self.assertEqual(self.layout(ppu, layers, **kwargs), expected)
def test_the_box_format_comes_from_the_compiled_graph(self):
for box_format, centre in (
("centre", True),
("corner", False),
("unnamed", True),
):
with self.subTest(box_format=box_format):
self.assertEqual(
self.layout(ANCHOR_FREE_PPU, ONE_SCALE_FREE, box_format=box_format),
PpuLayout(
anchor_based=False, grids=((100, 84),), centre_boxes=centre
),
)
def test_a_box_format_the_graph_does_not_answer_is_left_open(self):
cases = {
"no compiled graph at all": (ANCHOR_FREE_PPU, ONE_SCALE_FREE, {}),
"a graph without the node compile_config names": (
dict(ANCHOR_FREE_PPU, layer=[{"bbox": "/head/Missing"}]),
ONE_SCALE_FREE,
{"box_format": "centre"},
),
"a graph that builds its boxes from neither shape": (
ANCHOR_FREE_PPU,
ONE_SCALE_FREE,
{"box_format": "neither"},
),
"a graph section the reader cannot make sense of": (
ANCHOR_FREE_PPU,
ONE_SCALE_FREE,
{"box_format": "broken"},
),
"a grid-decoded head, where the question does not arise": (
ANCHOR_FREE_PPU,
THREE_SCALE_FREE,
{"box_format": "centre"},
),
}
for graph, (ppu, layers, kwargs) in cases.items():
with self.subTest(graph=graph):
self.assertIsNone(self.layout(ppu, layers, **kwargs).centre_boxes)
def test_nothing_is_read_from_a_model_without_ppu_metadata(self):
self.assertIsNone(read_ppu_layout(os.path.join(self.tmp.name, "missing")))
path = write_dxnn(self.tmp.name, None, [(80, 80, 3)])
self.assertIsNone(read_ppu_layout(path))
with open(path, "wb") as model:
model.write(b"ONNX" + b"\0" * 100)
self.assertIsNone(read_ppu_layout(path))
path = write_dxnn(self.tmp.name, ANCHOR_BASED_PPU, [(80, 80, 3), (40, 40, 3)])
os.truncate(path, os.path.getsize(path) - 40)
self.assertIsNone(read_ppu_layout(path))
class TestDeepxLayoutInference(unittest.TestCase):
def test_a_shape_and_class_count_pick_one_layout(self):
cases = {
"anchor-free, four columns ahead of the classes": (
[(1, 84, 8400)],
80,
YoloLayout.anchor_free,
84,
),
"anchor-free, row-major": ([(1, 8400, 84)], 80, YoloLayout.anchor_free, 84),
"anchor-based, an objectness column as well": (
[(1, 25200, 85)],
80,
YoloLayout.anchor,
85,
),
"anchor-based, channel-major": (
[(1, 85, 25200)],
80,
YoloLayout.anchor,
85,
),
"NMS in the head, a fixed run of corner records": (
[(1, 300, 6)],
80,
YoloLayout.nms_in_head,
None,
),
# only the label map can tell these two apart
"85 columns with 81 classes is anchor-free": (
[(1, 8400, 85)],
81,
YoloLayout.anchor_free,
85,
),
"85 columns with 80 classes is anchor-based": (
[(1, 8400, 85)],
80,
YoloLayout.anchor,
85,
),
# 6 columns is all three layouts, told apart by the row count
"6 columns and thousands of rows, one class": (
[(1, 25200, 6)],
1,
YoloLayout.anchor,
6,
),
"6 columns and thousands of rows, two classes": (
[(1, 8400, 6)],
2,
YoloLayout.anchor_free,
6,
),
"6 columns and a few hundred rows, one class": (
[(1, 300, 6)],
1,
YoloLayout.nms_in_head,
None,
),
"7 columns with two classes is only anchor-based": (
[(1, 8400, 7)],
2,
YoloLayout.anchor,
7,
),
# a square output matches the same width on both axes, which must
# not read as two candidate layouts
"a square output is not ambiguous with itself": (
[(1, 85, 85)],
80,
YoloLayout.anchor,
85,
),
"three NCHW maps with 255 channels are feature maps": (
[(1, 255, 80, 80), (1, 255, 40, 40), (1, 255, 20, 20)],
80,
YoloLayout.multipart,
None,
),
}
for head, (shapes, num_classes, layout, columns) in cases.items():
with self.subTest(head=head):
output = infer_yolo_layout(shapes, num_classes, False, False)
self.assertIs(output.layout, layout)
if columns is not None:
self.assertEqual(output.columns, columns)
def test_the_runtime_flags_outrank_the_shapes(self):
self.assertIs(layout_of([(8400,)], 80, ppu=True), YoloLayout.ppu)
self.assertIs(
layout_of([(1, -1, 6)], 80, dynamic_output=True), YoloLayout.nms_in_head
)
def test_a_shape_no_layout_fits_is_refused_with_the_reason(self):
cases = {
"fits two layouts": ([(1, 84, 6)], 80),
"labelmap_path": ([(1, 8400, 84)], 91),
"no output tensor": ([], 80),
"255 channels": (
[(1, 80, 80, 255), (1, 40, 40, 255), (1, 20, 20, 255)],
80,
),
"feature maps": ([(1, 8400, 80), (1, 8400, 4)], 80),
"80-class": ([(1, 24, 80, 80), (1, 24, 40, 40), (1, 24, 20, 20)], 3),
}
for reason, (shapes, num_classes) in cases.items():
with (
self.subTest(reason=reason),
self.assertRaisesRegex(ValueError, reason),
):
layout_of(shapes, num_classes)
class TestDeepxOutputValidation(unittest.TestCase):
def test_the_raw_yolox_head_is_read_for_the_configured_input(self):
for shapes, size in (
([(1, 8400, 85)], 640),
([(1, 85, 8400)], 640),
# 52*52 + 26*26 + 13*13 cells at 416
([(1, 3549, 85)], 416),
):
with self.subTest(shapes=shapes, size=size):
self.assertEqual(validate_yolox_outputs(shapes, 80, size, size), 85)
def test_another_head_under_yolox_is_refused_with_the_reason(self):
cases = {
"width and height": ([(1, 8400, 85)], 80, 416),
"labelmap_path": ([(1, 8400, 85)], 91, 640),
"yolo-generic": ([(1, 8400, 80), (1, 8400, 4)], 80, 640),
}
for reason, (shapes, num_classes, size) in cases.items():
with (
self.subTest(reason=reason),
self.assertRaisesRegex(ValueError, reason),
):
validate_yolox_outputs(shapes, num_classes, size, size)
def test_the_label_map_bounds_the_class_count(self):
self.assertEqual(class_count({0: "person", 79: "toothbrush"}), 80)
with self.assertRaisesRegex(ValueError, "labelmap_path"):
class_count({})
class TestDeepxRawDecode(unittest.TestCase):
def anchor_rows(self, rows) -> list:
out = np.zeros((1, len(rows), 85), dtype=np.float32)
for i, (cx, cy, w, h, obj, label, score) in enumerate(rows):
out[0, i, 0:4] = [cx, cy, w, h]
out[0, i, 4] = obj
out[0, i, 5 + label] = score
return [out]
def test_an_anchor_based_head_becomes_normalized_corners(self):
outputs = self.anchor_rows([(320.0, 160.0, 64.0, 32.0, 0.8, 3, 0.5)])
detections = decode_raw_anchor(outputs, 640, 640, 0.25, 0.45)
self.assertEqual(detections[0][0], 3)
self.assertAlmostEqual(detections[0][1], 0.4, places=5)
self.assertAlmostEqual(detections[0][2], 144 / 640, places=5)
self.assertAlmostEqual(detections[0][3], 288 / 640, places=5)
self.assertAlmostEqual(detections[0][4], 176 / 640, places=5)
self.assertAlmostEqual(detections[0][5], 352 / 640, places=5)
def test_a_channel_major_export_is_read_by_column_count(self):
outputs = self.anchor_rows([(320.0, 160.0, 64.0, 32.0, 1.0, 3, 0.9)])
detections = decode_raw_anchor(
[np.swapaxes(outputs[0], 1, 2)], 640, 640, 0.25, 0.45, columns=85
)
self.assertEqual(detections[0][0], 3)
self.assertAlmostEqual(detections[0][3], 288 / 640, places=5)
def test_rows_below_the_combined_threshold_are_dropped(self):
# 0.4 * 0.5 = 0.2, under the threshold both parts clear on their own
outputs = self.anchor_rows([(320.0, 320.0, 40.0, 80.0, 0.4, 3, 0.5)])
self.assertTrue(np.all(decode_raw_anchor(outputs, 640, 640, 0.25, 0.45) == 0))
def test_at_most_twenty_detections_are_returned(self):
rows = [(20.0 + 24 * i, 320.0, 16.0, 16.0, 1.0, i % 80, 0.9) for i in range(25)]
detections = decode_raw_anchor(self.anchor_rows(rows), 640, 640, 0.25, 0.45)
self.assertEqual(detections.shape, (20, 6))
self.assertEqual(int((detections[:, 1] > 0).sum()), 20)
def test_an_nms_in_head_output_is_read_without_running_nms(self):
out = np.array(
[
[
[100.0, 100.0, 200.0, 200.0, 0.9, 2.0],
[102.0, 102.0, 202.0, 202.0, 0.8, 2.0],
[300.0, 300.0, 400.0, 400.0, 0.1, 5.0],
]
],
dtype=np.float32,
)
detections = decode_raw_nms_in_head([out], 640, 640, 0.25)
self.assertEqual(detections[0][0], 2)
self.assertAlmostEqual(detections[0][1], 0.9, places=5)
self.assertAlmostEqual(detections[0][3], 100 / 640, places=5)
self.assertEqual(detections[1][0], 2)
self.assertAlmostEqual(detections[1][1], 0.8, places=5)
self.assertTrue(np.all(detections[2] == 0))
empty = np.zeros((1, 0, 6), dtype=np.float32)
self.assertTrue(np.all(decode_raw_nms_in_head([empty], 640, 640, 0.25) == 0))
class TestDeepxConfig(unittest.TestCase):
def test_a_device_string_resolves_to_an_npu_index(self):
for configured, index in (("PCIe:1", 1), ("2", 2), ("", 0)):
with self.subTest(device=configured):
self.assertEqual(resolve_device(configured), index)
def test_a_device_that_is_not_an_index_is_rejected(self):
"""The whole string has to be an index. Reading only the tail would
take the 1 out of "PCIe:0,PCIe:1" and bind to an NPU the config never
named, and several NPUs are configured as separate devices entries."""
for configured in (
"PCIe:the-fast-one",
"PCIe:0,PCIe:1",
"0,1",
"PCIe:0 PCIe:1",
"PCIe:-1",
"PCIe:",
):
with self.subTest(device=configured):
with self.assertRaises(ValueError):
resolve_device(configured)
with self.assertRaises(ValidationError):
DeepxDetectorConfig(type="deepx", device=configured)
def test_a_bad_device_is_refused_where_the_config_is_parsed(self):
"""parse_device builds the detector config to surface a bad device at
startup, so the comma-separated form fails there rather than binding a
detector process to the wrong NPU."""
self.assertEqual(parse_device("deepx:PCIe:1").device, "PCIe:1")
for raw in ("deepx:PCIe:0,PCIe:1", "deepx:the-fast-one"):
with self.subTest(raw=raw), self.assertRaises(DeviceParseError):
parse_device(raw)
def test_a_device_string_builds_this_detector_config(self):
"""Frigate turns a `deepx:PCIe:0` entry into the detector config with
the model already attached, which is the path app.py takes; a bare
constructor call does not exercise it."""
config = build_detector_config(
parse_device("deepx:PCIe:0"), model_with_type(ModelTypeEnum.yologeneric)
)
self.assertIsInstance(config, DeepxDetectorConfig)
self.assertEqual(config.device, "PCIe:0")
self.assertEqual(config.model.model_type, ModelTypeEnum.yologeneric)
def test_the_runtime_manifest_pins_its_wheels_to_the_version(self):
"""A PyPI path carries a per-file digest, so bumping DXRT_VERSION has
to rewrite the whole URL; a stale one installs the old wheel and fails
the sha256 on every user's first start."""
self.assertEqual(DEEPX_MANIFEST.version, DXRT_VERSION)
for artifact in DEEPX_MANIFEST.artifacts:
with self.subTest(url=artifact.url):
self.assertIn(f"dx_engine-{DXRT_VERSION}-", artifact.url)
def test_a_model_less_config_still_validates(self):
config = DeepxDetectorConfig(type="deepx")
self.assertIsNone(config.model)
class DeepxDetectorTestCase(unittest.TestCase):
def detector(
self,
model_type=ModelTypeEnum.yologeneric,
outputs_info=None,
ppu=False,
dynamic=False,
model_path="/nonexistent/model.dxnn",
) -> DeepxDetector:
dx_engine = MagicMock()
dx_engine.Configuration.ITEM.SERVICE = object()
session = dx_engine.InferenceEngine.return_value
session.get_output_tensors_info.return_value = (
[{"shape": [8400]}] if ppu else outputs_info or []
)
session.is_ppu.return_value = ppu
session.has_dynamic_output.return_value = dynamic
config = DeepxDetectorConfig(type="deepx")
config.model = model_with_type(model_type)
config.model.path = model_path
with (
# the detector writes the endpoint into the environment, which the
# rest of the suite shares when it runs in one process
patch.dict(os.environ),
patch.dict(sys.modules, {"dx_engine": dx_engine}),
patch.object(DeepxDetector, "activate_dependencies"),
patch("os.path.isfile", return_value=True),
):
return DeepxDetector(config)
def ppu_detector(
self, ppu, layers, model_type=ModelTypeEnum.yologeneric, box_format=None
) -> DeepxDetector:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
return self.detector(
model_type,
ppu=True,
model_path=write_dxnn(tmp.name, ppu, layers, box_format=box_format),
)
def detect(self, detector, outputs) -> np.ndarray:
detector.session.run.return_value = outputs
return detector.detect_raw(np.zeros((1, 640, 640, 3), np.uint8))
class TestDeepxModelType(DeepxDetectorTestCase):
def test_a_model_type_with_no_decoder_is_rejected(self):
for model_type in (ModelTypeEnum.ssd, ModelTypeEnum.dfine):
with (
self.subTest(model_type=model_type),
self.assertRaisesRegex(ValueError, model_type.value),
):
self.detector(model_type)
def test_supported_model_types_are_accepted(self):
for model_type, outputs_info in (
(ModelTypeEnum.yologeneric, [{"shape": [1, 84, 8400]}]),
(ModelTypeEnum.yolox, [{"shape": [1, 8400, 85]}]),
):
with self.subTest(model_type=model_type):
detector = self.detector(model_type, outputs_info)
self.assertEqual(detector.model_type, model_type)
class TestDeepxDetectorLoad(DeepxDetectorTestCase):
def test_the_layout_is_settled_at_load(self):
detector = self.detector(ModelTypeEnum.yologeneric, [{"shape": [1, 84, 8400]}])
self.assertIs(detector.output.layout, YoloLayout.anchor_free)
self.assertEqual(detector.output.columns, 84)
detector = self.detector(ModelTypeEnum.yolox, [{"shape": [1, 8400, 85]}])
self.assertIs(detector.output.layout, YoloLayout.yolox)
for model_type in (ModelTypeEnum.yologeneric, ModelTypeEnum.yolox):
with self.subTest(model_type=model_type):
detector = self.ppu_detector(
ANCHOR_FREE_PPU, THREE_SCALE_FREE, model_type=model_type
)
self.assertIs(detector.output.layout, YoloLayout.ppu)
self.assertEqual(detector.ppu_layout.scale_count, 3)
def test_a_model_the_detector_cannot_decode_is_refused_at_load(self):
cases = {
"Cannot decode DEEPX model": lambda: self.detector(
ModelTypeEnum.yologeneric, [{"shape": [1, 8400, 7]}]
),
"DX-COM 2.4.0": lambda: self.detector(ModelTypeEnum.yologeneric, ppu=True),
"face and pose": lambda: self.ppu_detector({"type": 2}, [(80, 80, 1)]),
"centre and size or as two corners": lambda: self.ppu_detector(
ANCHOR_FREE_PPU, ONE_SCALE_FREE
),
}
for reason, load in cases.items():
with (
self.subTest(reason=reason),
self.assertRaisesRegex(ValueError, reason),
):
load()
class TestDeepxDetectRaw(DeepxDetectorTestCase):
def test_a_ppu_record_decodes_by_the_head_in_the_model(self):
cases = {
"anchor-based, layer 2 of 3 at stride 32, the 373x326 anchor": (
(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS, None),
build_ppu_record((0.6, 0.4, 0.3, 0.7), label=5),
(5, (0.0, 0.380094, 0.864187, 0.589906)),
),
"anchor-based, layer 0 of 3 at stride 8, the 16x30 anchor": (
(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS, None),
build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 1, 0)),
(0, (None, 0.116750, None, None)),
),
"anchor-based, two scales: layer 0 is stride 16, not stride 8": (
(ANCHOR_BASED_PPU, TWO_SCALE_ANCHORS, None),
build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 1, 0)),
(0, (0.141156, 0.236031, 0.223844, 0.248969)),
),
"anchor-free, three scales: cell (10, 9) of stride 32": (
(ANCHOR_FREE_PPU, THREE_SCALE_FREE, None),
build_ppu_record((1.2, 0.5, 1.0, 0.5), grid=(9, 10, 0, 2), label=7),
(7, (0.433782, 0.492043, 0.516218, 0.627957)),
),
"anchor-free, one scale: the box fields are already pixels": (
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "centre"),
build_ppu_record((320.0, 160.0, 64.0, 32.0), label=3),
(3, (144 / 640, 288 / 640, 176 / 640, 352 / 640)),
),
"anchor-free, one scale: a sub-pixel box stays sub-pixel": (
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "centre"),
build_ppu_record((0.6, 0.4, 0.3, 0.7)),
(0, (None, 0.45 / 640, None, None)),
),
"a corner-format head reads the record as two corners": (
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "corner"),
build_ppu_record((100.0, 50.0, 300.0, 250.0), label=2),
(2, (50 / 640, 100 / 640, 250 / 640, 300 / 640)),
),
"a centre-format head reads it as a centre and size": (
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "centre"),
build_ppu_record((100.0, 50.0, 300.0, 250.0), label=2),
(2, (0.0, 0.0, 175 / 640, 250 / 640)),
),
}
for head, ((ppu, layers, box_format), record, (label, box)) in cases.items():
with self.subTest(head=head):
detector = self.ppu_detector(ppu, layers, box_format=box_format)
detections = self.detect(detector, [record])
self.assertEqual(detections[0][0], label)
for i, expected in enumerate(box, start=2):
if expected is not None:
self.assertAlmostEqual(detections[0][i], expected, places=5)
def test_the_strides_come_from_the_grids_in_the_model(self):
detector = self.ppu_detector(
ANCHOR_FREE_PPU, [(40, 40, 1), (20, 20, 1), (10, 10, 1)]
)
detections = self.detect(
detector,
[build_ppu_record((1.2, 0.5, 1.0, 0.5), grid=(9, 10, 0, 0), label=7)],
)
# layer 0 is stride 16: centre (11.2, 9.5) * 16, size e * 16 x sqrt(e) * 16
self.assertEqual(detections[0][0], 7)
self.assertAlmostEqual(detections[0][2], (152 - np.exp(0.5) * 8) / 640, 5)
self.assertAlmostEqual(detections[0][3], (179.2 - np.exp(1.0) * 8) / 640, 5)
def test_the_head_stays_what_the_model_said_across_frames(self):
detector = self.ppu_detector(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS)
first = self.detect(
detector, [build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 1, 0))]
)
# layer 0 of 3: stride 8, anchor 16x30
self.assertAlmostEqual(first[0][3], 0.116750, places=5)
second = self.detect(
detector, [build_ppu_record((0.5, 0.5, 0.4, 0.4), grid=(3, 4, 0, 1))]
)
# layer 1 of 3: stride 16, anchor 30x61, not layer 1 of 2
self.assertAlmostEqual(second[0][3], 0.0975, places=5)
detector = self.ppu_detector(
ANCHOR_FREE_PPU, ONE_SCALE_FREE, box_format="centre"
)
record = [build_ppu_record((100.0, 50.0, 300.0, 250.0), label=2)]
# centre (100, 50), size 300 x 250: the right edge lands at 250
for frame in range(2):
with self.subTest(frame=frame):
self.assertAlmostEqual(
self.detect(detector, record)[0][5], 250 / 640, places=5
)
def test_records_the_head_cannot_place_come_back_empty(self):
cases = {
"a level or box the anchor table does not carry": (
THREE_SCALE_ANCHORS,
[build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 2, 5))],
),
"a scale count Frigate has no anchor table for": (
FOUR_SCALE_ANCHORS,
[build_ppu_record((0.6, 0.4, 0.3, 0.7))],
),
"a record below the score threshold": (
THREE_SCALE_ANCHORS,
[build_ppu_record((0.6, 0.4, 0.3, 0.7), score=0.1)],
),
"an unexpected record width": (
THREE_SCALE_ANCHORS,
[np.zeros((1, 3, 16), dtype=np.uint8)],
),
**{
f"no records at all, shaped {shape}": (
THREE_SCALE_ANCHORS,
[np.zeros(shape, dtype=np.uint8)],
)
for shape in ((1, 0, PPU_RECORD_SIZE), (0, PPU_RECORD_SIZE), (0,))
},
}
for output, (layers, outputs) in cases.items():
with self.subTest(output=output):
detector = self.ppu_detector(ANCHOR_BASED_PPU, layers)
self.assertTrue(np.all(self.detect(detector, outputs) == 0))
def test_a_yolox_raw_head_is_decoded_through_the_grid(self):
detector = self.detector(ModelTypeEnum.yolox, [{"shape": [1, 8400, 85]}])
# cell (x 10, y 5) of the stride-8 grid is row 5 * 80 + 10
tensor = np.zeros((1, 8400, 85), np.float32)
tensor[0, 410, :5] = [0.5, 0.5, np.log(4.0), np.log(2.0), 0.9]
tensor[0, 410, 5 + 7] = 0.8
detections = self.detect(detector, [tensor])
# centre (84, 44), size 32 x 16
self.assertEqual(detections[0][0], 7)
self.assertAlmostEqual(detections[0][1], 0.72, places=5)
self.assertAlmostEqual(detections[0][2], 36 / 640, places=5)
self.assertAlmostEqual(detections[0][3], 68 / 640, places=5)
self.assertAlmostEqual(detections[0][4], 52 / 640, places=5)
self.assertAlmostEqual(detections[0][5], 100 / 640, places=5)
detector = self.detector(ModelTypeEnum.yolox, [{"shape": [1, 85, 8400]}])
detections = self.detect(detector, [np.swapaxes(tensor, 1, 2)])
self.assertAlmostEqual(detections[0][5], 100 / 640, places=5)
def test_a_raw_head_comes_back_as_frigates_detection_rows(self):
detector = self.detector(ModelTypeEnum.yologeneric, [{"shape": [1, 84, 8400]}])
output = np.zeros((1, 84, 8400), dtype=np.float32)
output[0, 0:4, 0] = [320.0, 160.0, 64.0, 32.0]
output[0, 4 + 2, 0] = 0.9
detections = self.detect(detector, [output])
self.assertEqual(detections.shape, (20, 6))
self.assertEqual(detections[0][0], 2)
self.assertAlmostEqual(detections[0][1], 0.9, places=5)
self.assertAlmostEqual(detections[0][3], 288 / 640, places=5)
+5
View File
@@ -59,6 +59,10 @@ class TestBuildDetectorConfig(unittest.TestCase):
def test_detectors_that_name_the_field_something_else(self):
self.assertEqual(self._build("cpu:4").num_threads, 4)
self.assertEqual(self._build("rknn:2").num_cores, 2)
self.assertEqual(
self._build("zmq:tcp://host.docker.internal:5555").endpoint,
"tcp://host.docker.internal:5555",
)
def test_device_is_coerced_to_the_detector_field_type(self):
self.assertEqual(self._build("tensorrt:1").device, 1)
@@ -66,6 +70,7 @@ class TestBuildDetectorConfig(unittest.TestCase):
def test_omitted_device_falls_back_to_the_detector_default(self):
self.assertEqual(self._build("cpu").num_threads, 3)
self.assertEqual(self._build("rknn").num_cores, 0)
self.assertEqual(self._build("zmq").endpoint, "ipc:///tmp/cache/zmq_detector")
self.assertEqual(self._build("openvino").device, "AUTO")
self.assertIsNone(self._build("edgetpu").device)
+44 -1
View File
@@ -25,7 +25,7 @@ class HardwareProbeTestCase(unittest.TestCase):
self.root = tempfile.TemporaryDirectory()
self.addCleanup(self.root.cleanup)
for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT"):
for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT", "LIB_ROOT"):
sub = os.path.join(self.root.name, name.split("_")[0].lower())
os.makedirs(sub, exist_ok=True)
patcher = patch.object(hardware, name, sub)
@@ -167,6 +167,29 @@ class TestNvidia(HardwareProbeTestCase):
class TestAccelerators(HardwareProbeTestCase):
def test_the_neural_engine_is_found_by_lighters_provider_library(self):
write(os.path.join(self.lib_root, "lighter", "liblighter_ane_ep.so"))
with patch.dict(os.environ, clear=False) as env:
env.pop("LIGHTER_ANE_EP", None)
ane = self.probe()["onnx:lighter"]
self.assertEqual(ane.detector, "onnx")
self.assertEqual(ane.units[0].device, "onnx")
self.assertEqual(ane.units[0].label, "Neural Engine")
def test_the_neural_engine_is_found_where_lighter_ane_ep_points(self):
library = os.path.join(self.root.name, "elsewhere", "liblighter_ane_ep.so")
write(library)
with patch.dict(os.environ, {"LIGHTER_ANE_EP": library}):
self.assertIn("onnx:lighter", self.probe())
def test_no_neural_engine_is_reported_without_the_library(self):
with patch.dict(os.environ, clear=False) as env:
env.pop("LIGHTER_ANE_EP", None)
self.assertNotIn("onnx:lighter", self.probe())
def test_hailo_is_found_by_its_device_node(self):
write(os.path.join(self.dev_root, "hailo0"))
@@ -184,6 +207,26 @@ class TestAccelerators(HardwareProbeTestCase):
)
self.assertFalse(memryx.unlimited)
def test_each_deepx_node_is_a_unit(self):
write(os.path.join(self.dev_root, "dxrt0"))
write(os.path.join(self.dev_root, "dxrt1"))
deepx = self.probe()["deepx"]
self.assertEqual(
[unit.device for unit in deepx.units],
["deepx:PCIe:0", "deepx:PCIe:1"],
)
def test_a_deepx_npu_is_unlimited(self):
# the host daemon multiplexes, so one module takes several processes
write(os.path.join(self.dev_root, "dxrt0"))
self.assertTrue(self.probe()["deepx"].unlimited)
def test_no_deepx_is_reported_without_a_node(self):
self.assertNotIn("deepx", self.probe())
def test_a_supported_rockchip_soc_is_reported(self):
write(
os.path.join(self.proc_root, "device-tree", "compatible"),
+10
View File
@@ -51,6 +51,16 @@ class TestFfmpegPresets(unittest.TestCase):
" ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
)
def test_ffmpeg_hwaccel_apple_preset_decodes_every_stream(self):
self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = (
"preset-apple-silicon-h265"
)
frigate_config = FrigateConfig(**self.default_ffmpeg)
cmd = " ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
assert "-c:v hevc_v4l2m2m" in cmd
assert "-c:v:1" not in cmd
assert "scale=1920:1080" in cmd
def test_ffmpeg_hwaccel_not_preset(self):
self.default_ffmpeg["cameras"]["back"]["ffmpeg"]["hwaccel_args"] = (
"-other-hwaccel args"
@@ -34,6 +34,12 @@ class HwaccelRecommendationTestCase(unittest.TestCase):
patcher.start()
self.addCleanup(patcher.stop)
self.sys_root = os.path.join(self.root.name, "sys")
os.makedirs(self.sys_root)
patcher = patch.object(hwaccel, "SYS_ROOT", self.sys_root)
patcher.start()
self.addCleanup(patcher.stop)
drm = patch.object(hwaccel, "enumerate_drm_devices", return_value={})
self.drm = drm.start()
self.addCleanup(drm.stop)
@@ -66,6 +72,12 @@ class HwaccelRecommendationTestCase(unittest.TestCase):
with open(os.path.join(self.proc_root, "cpuinfo"), "w") as f:
f.write(f"processor\t: 0\nmodel name\t: {model_name}\n")
def write_video_device(self, vendor: str) -> None:
device = os.path.join(self.sys_root, "class", "video4linux", "video0", "device")
os.makedirs(device)
with open(os.path.join(device, "vendor"), "w") as f:
f.write(f"{vendor}\n")
def write_device_tree(self) -> None:
os.makedirs(os.path.join(self.proc_root, "device-tree"), exist_ok=True)
with open(os.path.join(self.proc_root, "device-tree", "compatible"), "w") as f:
@@ -191,6 +203,22 @@ class TestAvailableFamilies(HwaccelRecommendationTestCase):
self.assertIn(recommended, [family.key for family in families])
class TestLighter(HwaccelRecommendationTestCase):
def test_lighters_media_engine_is_recommended(self):
self.write_video_device(hwaccel.LIGHTER_VIRTIO_VENDOR)
self.assertEqual(self.recommend(codecs={"h264"}), "apple-silicon")
self.assertEqual(
self.presets()["apple-silicon"],
{"h264": "preset-apple-silicon-h264", "h265": "preset-apple-silicon-h265"},
)
def test_another_virtio_media_device_is_not_lighters(self):
self.write_video_device("0x554d4551")
self.assertEqual(self.available(), [])
class TestCodecCoverage(HwaccelRecommendationTestCase):
def test_a_family_carries_a_preset_per_codec(self):
self.assertEqual(
+120 -58
View File
@@ -16,9 +16,7 @@ from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
# kinds that switch on the lifecycle knobs, so the tests do not depend on the
# values the real catalog picks
BATCHED = NoticeKind(
"batched", NoticeSeverity.warning, "system", batch_repeats=True, reopen_at_count=3
)
BATCHED = NoticeKind("batched", NoticeSeverity.warning, "system", batch_repeats=True)
PRUNED = NoticeKind("pruned", NoticeSeverity.info, "system", keep_latest=2)
TEST_KINDS = {kind.key: kind for kind in (BATCHED, PRUNED)}
@@ -77,6 +75,11 @@ class RegistryTestCase(unittest.TestCase):
mock_datetime.now.return_value.timestamp.return_value = timestamp
self.registry.raise_notice(kind, **kwargs)
def _acknowledge_at(self, timestamp: float, row_id: str) -> None:
with patch("frigate.notices.registry.datetime") as mock_datetime:
mock_datetime.now.return_value.timestamp.return_value = timestamp
self.registry.acknowledge(row_id)
class TestNoticeRegistry(RegistryTestCase):
def test_raise_inserts_and_counts_one_occurrence(self):
@@ -122,34 +125,98 @@ class TestNoticeRegistry(RegistryTestCase):
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
self.listener.assert_not_called()
def test_dismissal_survives_a_re_raise(self):
def test_acknowledged_notice_returns_on_a_re_raise(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
self.assertEqual(self.registry.active(), [])
self.registry.raise_notice("detector_stuck", scope="ov", params={})
notice = self.registry.active()[0]
self.assertEqual(notice["count"], 2)
self.assertIsNone(notice["acknowledged_at"])
def test_muted_notice_stays_hidden_on_a_re_raise(self):
self.registry.raise_notice("skipped_detections", scope="front_door", params={})
self.assertTrue(self.registry.dismiss("skipped_detections:front_door"))
self.assertTrue(self.registry.mute("skipped_detections:front_door"))
self.registry.raise_notice("skipped_detections", scope="front_door", params={})
self.assertEqual(self.registry.active(), [])
history = self.registry.active(include_dismissed=True)
self.assertEqual(history[0]["count"], 2)
self.assertIsNotNone(history[0]["dismissed_at"])
hidden = self.registry.active(include_hidden=True)
self.assertEqual(hidden[0]["count"], 2)
self.assertIsNotNone(hidden[0]["muted_at"])
def test_dismiss_counts_once(self):
def test_muting_an_acknowledged_notice_replaces_the_acknowledgement(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.acknowledge("detector_stuck:ov")
self.assertTrue(self.registry.dismiss("detector_stuck:ov"))
self.assertTrue(self.registry.dismiss("detector_stuck:ov"))
self.assertTrue(self.registry.mute("detector_stuck:ov"))
self.assertEqual(self.registry.stats()[0]["dismissals"], 1)
notice = self.registry.active(include_hidden=True)[0]
self.assertIsNone(notice["acknowledged_at"])
self.assertIsNotNone(notice["muted_at"])
def test_dismiss_unknown_id(self):
self.assertFalse(self.registry.dismiss("nope"))
def test_dismissed_hidden_unless_requested(self):
def test_acknowledging_a_muted_notice_keeps_it_muted(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.dismiss("detector_stuck:ov")
self.registry.mute("detector_stuck:ov")
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
notice = self.registry.active(include_hidden=True)[0]
self.assertIsNone(notice["acknowledged_at"])
self.assertIsNotNone(notice["muted_at"])
def test_acknowledge_and_mute_each_count_once(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.raise_notice("shm_too_low", params={})
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
self.assertTrue(self.registry.mute("shm_too_low"))
self.assertTrue(self.registry.mute("shm_too_low"))
stats = {s["kind"]: s for s in self.registry.stats()}
self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
self.assertEqual(stats["detector_stuck"]["mutes"], 0)
self.assertEqual(stats["shm_too_low"]["mutes"], 1)
def test_unknown_ids_are_rejected(self):
self.assertFalse(self.registry.acknowledge("nope"))
self.assertFalse(self.registry.mute("nope"))
self.assertFalse(self.registry.unhide("nope"))
def test_kinds_that_never_repeat_cannot_be_acknowledged(self):
self.registry.raise_notice(
"update_available", scope="0.19.1", params={"version": "0.19.1"}
)
self.assertFalse(self.registry.acknowledge("update_available:0.19.1"))
self.assertFalse(
self.registry.acknowledge("config:detect:fps-greater-than-five:global")
)
self.assertEqual(self.registry.active()[0]["acknowledgeable"], False)
def test_hidden_notices_listed_only_when_requested(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.acknowledge("detector_stuck:ov")
self.assertEqual(self.registry.active(), [])
self.assertEqual(len(self.registry.active(include_dismissed=True)), 1)
self.assertEqual(len(self.registry.active(include_hidden=True)), 1)
def test_unhide_shows_a_notice_and_drops_a_check_mute(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.mute("detector_stuck:ov")
self.registry.mute("config:detect:fps-greater-than-five:global")
self.assertTrue(self.registry.unhide("detector_stuck:ov"))
self.assertTrue(
self.registry.unhide("config:detect:fps-greater-than-five:global")
)
notice = self.registry.active()[0]
self.assertIsNone(notice["muted_at"])
self.assertEqual(self.registry.muted_checks(), [])
def test_resolve_deletes_and_keeps_stats(self):
self.registry.raise_notice(
@@ -160,7 +227,7 @@ class TestNoticeRegistry(RegistryTestCase):
self.registry.resolve("model_download_failed", "yolo/model.onnx")
self.registry.resolve("model_download_failed", "yolo/model.onnx")
self.assertEqual(self.registry.active(include_dismissed=True), [])
self.assertEqual(self.registry.active(include_hidden=True), [])
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
self.listener.assert_called_once()
@@ -237,45 +304,47 @@ class TestNoticeRegistry(RegistryTestCase):
ids, ["model_download_failed:front_door", "skipped_detections:garage"]
)
def test_resolve_camera_drops_that_cameras_check_dismissals(self):
def test_resolve_camera_drops_that_cameras_check_mutes(self):
for check_id in (
"stream:front_door:0:probe",
"config:detect:fps-greater-than-five:camera.front_door",
"config:detect:fps-greater-than-five:global",
"stream:garage:0:probe",
):
self.assertTrue(self.registry.dismiss(check_id))
self.assertTrue(self.registry.mute(check_id))
self.registry.resolve_camera("front_door")
ids = sorted(check["id"] for check in self.registry.dismissed_checks())
ids = sorted(check["id"] for check in self.registry.muted_checks())
self.assertEqual(
ids,
["config:detect:fps-greater-than-five:global", "stream:garage:0:probe"],
)
def test_camera_named_global_keeps_global_check_dismissals(self):
self.registry.dismiss("config:detect:fps-greater-than-five:global")
self.registry.dismiss("config:detect:fps-greater-than-five:camera.global")
def test_camera_named_global_keeps_global_check_mutes(self):
self.registry.mute("config:detect:fps-greater-than-five:global")
self.registry.mute("config:detect:fps-greater-than-five:camera.global")
self.registry.resolve_camera("global")
ids = [check["id"] for check in self.registry.dismissed_checks()]
ids = [check["id"] for check in self.registry.muted_checks()]
self.assertEqual(ids, ["config:detect:fps-greater-than-five:global"])
def test_purge_dismissed_keeps_active_notices_and_counts(self):
def test_unhide_all_shows_every_notice_and_keeps_counts(self):
self.registry.raise_notice("detector_stuck", scope="ov", params={})
self.registry.raise_notice("skipped_detections", scope="garage", params={})
self.registry.dismiss("detector_stuck:ov")
self.registry.dismiss("config:detect:fps-greater-than-five:camera.garage")
self.registry.acknowledge("detector_stuck:ov")
self.registry.mute("skipped_detections:garage")
self.registry.mute("config:detect:fps-greater-than-five:camera.garage")
self.assertEqual(self.registry.purge_dismissed(), 2)
self.registry.unhide_all()
ids = [n["id"] for n in self.registry.active(include_dismissed=True)]
self.assertEqual(ids, ["skipped_detections:garage"])
self.assertEqual(self.registry.dismissed_checks(), [])
dismissals = {s["kind"]: s["dismissals"] for s in self.registry.stats()}
self.assertEqual(dismissals["detector_stuck"], 1)
ids = sorted(n["id"] for n in self.registry.active())
self.assertEqual(ids, ["detector_stuck:ov", "skipped_detections:garage"])
self.assertEqual(self.registry.muted_checks(), [])
stats = {s["kind"]: s for s in self.registry.stats()}
self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
self.assertEqual(stats["skipped_detections"]["mutes"], 1)
class TestApply(RegistryTestCase):
@@ -349,7 +418,7 @@ class TestLifecycleKnobs(RegistryTestCase):
self.registry.flush()
self.assertEqual(self.registry.active(include_dismissed=True), [])
self.assertEqual(self.registry.active(include_hidden=True), [])
self.listener.assert_not_called()
def test_a_new_row_starts_without_stale_repeats(self):
@@ -362,32 +431,26 @@ class TestLifecycleKnobs(RegistryTestCase):
self.assertEqual(self.registry.active()[0]["count"], 1)
def test_a_dismissed_notice_reopens_at_the_count(self):
self.registry.raise_notice("batched")
self.registry.dismiss("batched")
def test_an_acknowledged_notice_returns_when_a_later_repeat_flushes(self):
self._raise_at(1000.0, "batched")
self._acknowledge_at(1010.0, "batched")
self.registry.raise_notice("batched")
self.registry.flush()
self._raise_at(1020.0, "batched")
self.assertEqual(self.registry.active(), [])
self.registry.raise_notice("batched")
self.registry.flush()
notice = self.registry.active()[0]
self.assertEqual(notice["count"], BATCHED.reopen_at_count)
self.assertIsNone(notice["dismissed_at"])
self.assertEqual(self.registry.active()[0]["count"], 2)
def test_a_notice_dismissed_past_the_count_stays_dismissed(self):
for _ in range(3):
self.registry.raise_notice("batched")
self.registry.flush()
self.registry.dismiss("batched")
def test_repeats_held_from_before_an_acknowledgement_stay_hidden(self):
self._raise_at(1000.0, "batched")
self._raise_at(1005.0, "batched")
self._acknowledge_at(1010.0, "batched")
self.registry.raise_notice("batched")
self.registry.flush()
self.assertEqual(self.registry.active(), [])
self.assertEqual(self.registry.active(include_dismissed=True)[0]["count"], 4)
self.assertEqual(self.registry.active(include_hidden=True)[0]["count"], 2)
def test_keep_latest_drops_the_oldest_rows(self):
for index, scope in enumerate(("a", "b", "c")):
@@ -410,14 +473,13 @@ class TestCatalogLifecycles(RegistryTestCase):
ids = [n["id"] for n in self.registry.active()]
self.assertEqual(ids, ["update_available:0.19.1"])
def test_a_dismissed_failed_login_burst_reopens_at_five_attempts(self):
def test_an_acknowledged_failed_login_burst_returns_on_the_next_attempt(self):
self.registry.raise_notice("failed_login", scope="1000")
self.registry.dismiss("failed_login:1000")
self.registry.acknowledge("failed_login:1000")
for _ in range(4):
self.registry.raise_notice("failed_login", scope="1000")
self.registry.raise_notice("failed_login", scope="1000")
self.registry.flush()
burst = self.registry.active()[0]
self.assertEqual(burst["count"], 5)
self.assertIsNone(burst["dismissed_at"])
self.assertEqual(burst["count"], 2)
self.assertIsNone(burst["acknowledged_at"])
+147
View File
@@ -1,5 +1,7 @@
"""Tests for the device each model runner reports after loading."""
import os
import tempfile
import threading
import unittest
from unittest.mock import MagicMock, patch
@@ -38,6 +40,7 @@ class TestRunnerDeviceName(unittest.TestCase):
self._onnx(["OpenVINOExecutionProvider"]).device_name, "OpenVINO"
)
self.assertEqual(self._onnx(["CPUExecutionProvider"]).device_name, "CPU")
self.assertEqual(self._onnx(["LighterANE"]).device_name, "Neural Engine")
self.assertEqual(self._onnx(["ROCMExecutionProvider"]).device_name, "ROCM")
self.assertEqual(self._onnx([]).device_name, "CPU")
@@ -129,3 +132,147 @@ class TestLoadedDeviceSnapshot(unittest.TestCase):
finally:
stop.set()
thread.join(timeout=5)
class TestLighterANE(unittest.TestCase):
"""lighter's plugin provider, picked up by the ONNX session setup."""
def setUp(self):
loaded_devices.clear()
self.root = tempfile.TemporaryDirectory()
self.addCleanup(self.root.cleanup)
self.library = os.path.join(self.root.name, "liblighter_ane_ep.so")
env = patch.dict(os.environ, {"LIGHTER_ANE_EP": self.library})
env.start()
self.addCleanup(env.stop)
def _device(self) -> MagicMock:
device = MagicMock()
device.ep_name = "LighterANE"
return device
def test_no_devices_without_the_library(self):
with patch.object(detection_runners.ort, "get_ep_devices") as get_ep_devices:
self.assertEqual(detection_runners.get_lighter_ane_devices(), [])
get_ep_devices.assert_not_called()
def test_the_provider_is_registered_once(self):
open(self.library, "w").close()
device = self._device()
other = MagicMock()
other.ep_name = "CPUExecutionProvider"
with (
patch.object(
detection_runners.ort,
"get_ep_devices",
side_effect=[[other], [other, device], [other, device]],
),
patch.object(
detection_runners.ort, "register_execution_provider_library"
) as register,
):
self.assertEqual(detection_runners.get_lighter_ane_devices(), [device])
self.assertEqual(detection_runners.get_lighter_ane_devices(), [device])
register.assert_called_once_with("LighterANE", self.library)
def test_a_provider_that_will_not_load_is_skipped(self):
open(self.library, "w").close()
with (
patch.object(detection_runners.ort, "get_ep_devices", return_value=[]),
patch.object(
detection_runners.ort,
"register_execution_provider_library",
side_effect=RuntimeError("not a provider"),
),
):
self.assertEqual(detection_runners.get_lighter_ane_devices(), [])
def test_the_session_runs_on_the_neural_engine(self):
device = self._device()
session = MagicMock()
session.get_providers.return_value = ["LighterANE", "CPUExecutionProvider"]
options = MagicMock()
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[device]
),
patch.object(
detection_runners, "get_ort_session_options", return_value=options
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
) as inference_session,
):
runner = get_optimized_runner("/models/yolo.onnx", "AUTO", "yolo-generic")
options.add_provider_for_devices.assert_called_once_with([device], {})
inference_session.assert_called_once_with(
"/models/yolo.onnx", sess_options=options
)
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(
loaded_devices["/models/yolo.onnx"], ("yolo-generic", "Neural Engine")
)
def test_a_model_the_neural_engine_cannot_load_uses_the_default_providers(self):
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[MagicMock()]
),
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners, "is_openvino_gpu_npu_available", return_value=False
),
patch.object(
detection_runners.ort,
"InferenceSession",
side_effect=[RuntimeError("unsupported"), session],
) as inference_session,
patch.object(
detection_runners, "get_ort_session_options", return_value=MagicMock()
),
self.assertLogs(detection_runners.logger, level="WARNING"),
):
runner = get_optimized_runner("/models/jina.onnx", "AUTO", "jina-v2")
self.assertEqual(inference_session.call_count, 2)
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(loaded_devices["/models/jina.onnx"], ("jina-v2", "CPU"))
def test_a_cpu_model_stays_on_the_cpu(self):
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[MagicMock()]
) as ane,
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
),
patch.object(
detection_runners, "get_ort_session_options", return_value=None
),
):
get_optimized_runner("/models/arcface.onnx", "CPU", "arcface")
ane.assert_not_called()
+86 -18
View File
@@ -1,10 +1,14 @@
"""Tests for the skipped detection rate and the notice it can raise."""
"""Tests for the per-camera episode notices: skipped detections and high CPU."""
import unittest
from unittest.mock import patch
from unittest.mock import call, patch
from frigate.stats import emitter
from frigate.stats.emitter import SkippedDetectionsTracker
from frigate.stats.emitter import (
SKIPPED_DETECTIONS_PCT,
EpisodeTracker,
cpu_average,
)
from frigate.stats.util import skipped_percent
@@ -19,18 +23,18 @@ class TestSkippedPercent(unittest.TestCase):
self.assertEqual(skipped_percent(2.0, 5.0, False), 0.0)
class TestSkippedDetectionsTracker(unittest.TestCase):
class TestEpisodeTracker(unittest.TestCase):
def _cameras(self, pct: float) -> dict:
return {"front_door": {"skipped_pct": pct}}
return {"front_door": pct}
def test_below_threshold_never_qualifies(self):
tracker = SkippedDetectionsTracker()
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
for tick in range(10):
self.assertEqual(tracker.update(self._cameras(4.9), tick * 15.0), [])
def test_qualifies_once_after_the_hold(self):
tracker = SkippedDetectionsTracker()
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
results = [
tracker.update(self._cameras(10.0), tick * 15.0) for tick in range(8)
@@ -42,7 +46,7 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
self.assertEqual(results[5:], [[], [], []])
def test_a_dip_restarts_the_hold(self):
tracker = SkippedDetectionsTracker()
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
for now, pct in ((0.0, 10.0), (15.0, 10.0), (30.0, 10.0), (45.0, 2.0)):
self.assertEqual(tracker.update(self._cameras(pct), now), [])
@@ -51,7 +55,7 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
self.assertEqual(tracker.update(self._cameras(10.0), 120.0), ["front_door"])
def test_recovery_then_relapse_is_a_new_episode(self):
tracker = SkippedDetectionsTracker()
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update(self._cameras(10.0), 0.0)
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), ["front_door"])
@@ -61,17 +65,15 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
self.assertEqual(tracker.update(self._cameras(10.0), 150.0), ["front_door"])
def test_cameras_are_tracked_separately(self):
tracker = SkippedDetectionsTracker()
tracker.update({"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 0.0}}, 0.0)
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update({"a": 10.0, "b": 0.0}, 0.0)
qualified = tracker.update(
{"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 10.0}}, 60.0
)
qualified = tracker.update({"a": 10.0, "b": 10.0}, 60.0)
self.assertEqual(qualified, ["a"])
def test_a_removed_camera_starts_a_new_episode(self):
tracker = SkippedDetectionsTracker()
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update(self._cameras(10.0), 0.0)
tracker.update({}, 15.0)
tracker.update(self._cameras(10.0), 30.0)
@@ -79,6 +81,25 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
self.assertEqual(tracker.update(self._cameras(10.0), 90.0), ["front_door"])
def test_a_missing_value_ends_the_episode(self):
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
tracker.update(self._cameras(10.0), 0.0)
tracker.update({"front_door": None}, 30.0)
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
class TestCpuAverage(unittest.TestCase):
def test_reads_the_lifetime_average(self):
usages = {"42": {"cpu": "80.0", "cpu_average": "25.0"}}
self.assertEqual(cpu_average(usages, 42), 25.0)
def test_unknown_process_has_no_average(self):
self.assertIsNone(cpu_average({}, 42))
self.assertIsNone(cpu_average({"42": {"cpu_average": "25.0"}}, None))
self.assertIsNone(cpu_average({"42": {"cpu_average": ""}}, 42))
class TestEmitterNotices(unittest.TestCase):
def setUp(self):
@@ -94,15 +115,25 @@ class TestEmitterNotices(unittest.TestCase):
def _emitter(self) -> emitter.StatsEmitter:
stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter)
stats_emitter.skipped_detections = SkippedDetectionsTracker()
stats_emitter.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
stats_emitter.ffmpeg_cpu = EpisodeTracker(emitter.FFMPEG_HIGH_CPU_PCT)
stats_emitter.detect_cpu = EpisodeTracker(emitter.DETECT_HIGH_CPU_PCT)
stats_emitter._shm_checked = False
stats_emitter._shm_params = None
return stats_emitter
def _stats(self, uptime: int, pct: float) -> dict:
def _stats(
self, uptime: int, pct: float, ffmpeg_cpu: str = "5.0", detect_cpu: str = "5.0"
) -> dict:
return {
"service": {"uptime": uptime, "storage": {"/dev/shm": {}}},
"cameras": {"front_door": {"skipped_pct": pct}},
"cameras": {
"front_door": {"skipped_pct": pct, "ffmpeg_pid": 10, "pid": 11}
},
"cpu_usages": {
"10": {"cpu_average": ffmpeg_cpu},
"11": {"cpu_average": detect_cpu},
},
}
def test_qualified_camera_raises_a_notice(self):
@@ -115,6 +146,43 @@ class TestEmitterNotices(unittest.TestCase):
"skipped_detections", scope="front_door", params={"pct": 12.5}
)
def test_high_cpu_raises_a_notice_per_process(self):
stats_emitter = self._emitter()
for uptime, now in ((300, 0.0), (360, 60.0)):
stats_emitter._update_notices(
self._stats(uptime, 0.0, ffmpeg_cpu="25.0", detect_cpu="45.0"), now
)
self.assertEqual(
self.raise_notice.call_args_list,
[
call("ffmpeg_high_cpu", scope="front_door", params={"cpu": 25.0}),
call("detect_high_cpu", scope="front_door", params={"cpu": 45.0}),
],
)
def test_cpu_below_each_threshold_raises_nothing(self):
stats_emitter = self._emitter()
for uptime, now in ((300, 0.0), (360, 60.0)):
stats_emitter._update_notices(
self._stats(uptime, 0.0, ffmpeg_cpu="19.0", detect_cpu="39.0"), now
)
self.raise_notice.assert_not_called()
def test_missing_cpu_stats_raise_nothing(self):
stats_emitter = self._emitter()
for uptime, now in ((300, 0.0), (360, 60.0)):
stats = self._stats(uptime, 0.0)
del stats["cpu_usages"]
stats_emitter._update_notices(stats, now)
self.raise_notice.assert_not_called()
self.assertEqual(self.flush_notices.call_count, 2)
def test_startup_window_is_ignored(self):
stats_emitter = self._emitter()
+7 -4
View File
@@ -56,10 +56,13 @@ class EventsPerSecond:
self._start = now
# compute the (approximate) events in the last n seconds
self.expire_timestamps(now)
seconds = min(now - self._start, self._last_n_seconds)
# avoid divide by zero
if seconds == 0:
seconds = 1
# rate over at least one second (or the whole window, if shorter),
# so a burst of events right after start() is not divided by a
# tiny window
seconds = max(
min(now - self._start, self._last_n_seconds),
min(1.0, self._last_n_seconds),
)
return len(self._timestamps) / seconds
# remove aged out timestamps
+33 -1
View File
@@ -9,6 +9,7 @@ and resolve it per camera against that camera's detect stream.
"""
import logging
import os
import re
from pydantic import BaseModel, Field
@@ -23,14 +24,20 @@ from frigate.util.services import enumerate_drm_devices
logger = logging.getLogger(__name__)
# root the /proc reads use, so tests can point them at a fixture tree
# roots the /proc and /sys reads use, so tests can point them at a fixture tree
PROC_ROOT = "/proc"
SYS_ROOT = "/sys"
ANY_CODEC = "any"
# a Raspberry Pi has no detection hardware of its own, so it gets a key here
RASPBERRY_PI = "raspberrypi"
# lighter (https://github.com/fieldwork-ai/lighter) decodes on a Mac's media
# engine through virtio-media devices, which carry its virtio vendor id "LGHT"
LIGHTER_MEDIA = "lighter"
LIGHTER_VIRTIO_VENDOR = "0x4c474854"
# ffprobe names h265 streams hevc
CODEC_ALIASES = {"hevc": "h265"}
@@ -52,6 +59,7 @@ DECODE_HARDWARE = (
"rknn",
"openvino:GPU",
"onnx:amd",
LIGHTER_MEDIA,
RASPBERRY_PI,
)
@@ -98,6 +106,10 @@ FAMILY_RPI = HwaccelFamily(
key="rpi",
presets={"h264": "preset-rpi-64-h264", "h265": "preset-rpi-64-h265"},
)
FAMILY_APPLE = HwaccelFamily(
key="apple-silicon",
presets={"h264": "preset-apple-silicon-h264", "h265": "preset-apple-silicon-h265"},
)
def _read(path: str) -> str | None:
@@ -139,6 +151,20 @@ def _is_raspberry_pi() -> bool:
return "raspberrypi" in compatible
def _has_lighter_media() -> bool:
video = f"{SYS_ROOT}/class/video4linux"
try:
devices = os.listdir(video)
except OSError:
return False
return any(
_read(f"{video}/{device}/device/vendor") == LIGHTER_VIRTIO_VENDOR
for device in devices
)
def _intel_families(generation: int | None) -> list[HwaccelFamily]:
"""vaapi drives every Intel GPU, qsv only those from gen8 on."""
if generation is not None and generation < INTEL_QSV_SUPPORTED_GEN:
@@ -164,6 +190,9 @@ def _families(key: str, generation: int | None) -> list[HwaccelFamily]:
if key == "onnx:amd":
return [FAMILY_VAAPI]
if key == LIGHTER_MEDIA:
return [FAMILY_APPLE]
if key == RASPBERRY_PI:
return [FAMILY_RPI]
@@ -193,6 +222,9 @@ def _decode_hardware(detector_key: str | None) -> list[str]:
"""
present = {found.key for found in hardware_prober.probe()}
if _has_lighter_media():
present.add(LIGHTER_MEDIA)
if _is_raspberry_pi():
present.add(RASPBERRY_PI)
+6 -3
View File
@@ -18,18 +18,21 @@ def migrate(migrator, database, fake=False, **kwargs):
'"first_seen" DATETIME NOT NULL, '
'"last_seen" DATETIME NOT NULL, '
'"count" INTEGER NOT NULL, '
'"dismissed_at" DATETIME)'
'"acknowledged_at" DATETIME, '
'"muted_at" DATETIME)'
)
migrator.sql('CREATE INDEX IF NOT EXISTS "notice_kind" ON "notice" ("kind")')
migrator.sql(
'CREATE TABLE IF NOT EXISTS "noticestats" ('
'"kind" VARCHAR(50) NOT NULL PRIMARY KEY, '
'"occurrences" INTEGER NOT NULL, '
'"dismissals" INTEGER NOT NULL, '
'"acknowledgements" INTEGER NOT NULL, '
'"mutes" INTEGER NOT NULL, '
'"first_seen" DATETIME NOT NULL, '
'"last_seen" DATETIME NOT NULL, '
'"reported_occurrences" INTEGER NOT NULL DEFAULT 0, '
'"reported_dismissals" INTEGER NOT NULL DEFAULT 0)'
'"reported_acknowledgements" INTEGER NOT NULL DEFAULT 0, '
'"reported_mutes" INTEGER NOT NULL DEFAULT 0)'
)
+3 -3
View File
@@ -49,7 +49,7 @@ export interface ApiMockOverrides {
};
users?: { username: string; role: string }[];
notices?: unknown[];
dismissedChecks?: unknown[];
mutedChecks?: unknown[];
/** camera name to the ffprobe entries returned for `paths=camera:<name>` */
ffprobe?: Record<string, unknown[]>;
}
@@ -238,8 +238,8 @@ export class ApiMocker {
await this.page.route("**/api/notices", (route) =>
route.fulfill({ json: overrides?.notices ?? [] }),
);
await this.page.route("**/api/notices/dismissed_checks", (route) =>
route.fulfill({ json: overrides?.dismissedChecks ?? [] }),
await this.page.route("**/api/notices/muted_checks", (route) =>
route.fulfill({ json: overrides?.mutedChecks ?? [] }),
);
// Users. GET lists them; POST/PUT (create, password) just succeed, so
+258 -121
View File
@@ -1,7 +1,8 @@
/**
* Health tab tests -- MEDIUM tier.
*
* Default tab, notice list rendering, dismiss, empty state, update notice.
* Default tab, notice list rendering, acknowledge and mute, empty state,
* update notice.
*/
import { test, expect } from "../fixtures/frigate-test";
@@ -23,7 +24,9 @@ const ERROR_NOTICE = {
first_seen: NOW - 600,
last_seen: NOW,
count: 2,
dismissed_at: null,
acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
};
const EVENT_NOTICE = {
@@ -37,7 +40,9 @@ const EVENT_NOTICE = {
first_seen: NOW - 7200,
last_seen: NOW - 60,
count: 3,
dismissed_at: null,
acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
};
test.describe("System — Health tab @medium", () => {
@@ -63,54 +68,61 @@ test.describe("System — Health tab @medium", () => {
"Downloading model.onnx for yolo failed: timeout",
);
await expect(rows.nth(0)).toContainText("2 times");
await expect(
rows.nth(0).getByRole("button", { name: "Dismiss" }),
).toBeVisible();
await expect(rows.nth(1)).toContainText("Detector ov was restarted");
await expect(rows.nth(1)).toContainText("3 times");
await expect(
rows.nth(1).getByRole("button", { name: "Dismiss" }),
).toBeVisible();
for (const index of [0, 1]) {
await expect(
rows.nth(index).getByRole("button", { name: "Acknowledge" }),
).toBeVisible();
await expect(
rows.nth(index).getByRole("button", { name: "Mute", exact: true }),
).toBeVisible();
}
});
test("dismiss posts and removes the row", async ({ frigateApp }) => {
await frigateApp.installDefaults({
stats: QUIET_STATS,
notices: [EVENT_NOTICE],
for (const action of ["acknowledge", "mute"] as const) {
test(`${action} posts and removes the row`, async ({ frigateApp }) => {
await frigateApp.installDefaults({
stats: QUIET_STATS,
notices: [EVENT_NOTICE],
});
// the list shrinks after the POST so the refetch shows the row gone
let hidden = false;
await frigateApp.page.route("**/api/notices", (route) =>
route.fulfill({ json: hidden ? [] : [EVENT_NOTICE] }),
);
await frigateApp.page.route(
`**/api/notices/detector_stuck/${action}`,
(route) => {
hidden = true;
return route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/system#health");
const request = frigateApp.page.waitForRequest(
(req) =>
req.url().includes(`/api/notices/detector_stuck/${action}`) &&
req.method() === "POST",
);
await frigateApp.page
.getByTestId("health-problem-notice:detector_stuck")
.getByRole("button", {
name: action === "mute" ? "Mute" : "Acknowledge",
exact: true,
})
.click();
await request;
await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"),
).toHaveCount(0, { timeout: 5_000 });
await expect(
frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible();
});
// the list shrinks after the dismiss so the refetch shows the row gone
let dismissed = false;
await frigateApp.page.route("**/api/notices", (route) =>
route.fulfill({ json: dismissed ? [] : [EVENT_NOTICE] }),
);
await frigateApp.page.route(
"**/api/notices/detector_stuck/dismiss",
(route) => {
dismissed = true;
return route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/system#health");
const request = frigateApp.page.waitForRequest(
(req) =>
req.url().includes("/api/notices/detector_stuck/dismiss") &&
req.method() === "POST",
);
await frigateApp.page
.getByTestId("health-problem-notice:detector_stuck")
.getByRole("button", { name: "Dismiss" })
.click();
await request;
await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"),
).toHaveCount(0, { timeout: 5_000 });
await expect(
frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible();
});
}
test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS });
@@ -140,7 +152,9 @@ test.describe("System — Health tab @medium", () => {
first_seen: NOW - 3600,
last_seen: NOW,
count: 1,
dismissed_at: null,
acknowledgeable: false,
acknowledged_at: null,
muted_at: null,
},
],
});
@@ -156,10 +170,23 @@ test.describe("System — Health tab @medium", () => {
"href",
"https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0",
);
await expect(row.getByRole("button", { name: "Dismiss" })).toBeVisible();
await expect(
row.getByRole("button", { name: "Mute", exact: true }),
).toBeVisible();
await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount(
0,
);
});
test("the filter shows dismissed notices without a Dismiss button", async ({
const ACKNOWLEDGED_NOTICE = {
...EVENT_NOTICE,
id: "detector_stuck:coral",
params: { detector: "coral" },
acknowledged_at: NOW - 60,
};
const MUTED_NOTICE = { ...ERROR_NOTICE, muted_at: NOW - 120 };
test("the filter shows hidden notices marked by how they were hidden", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
@@ -169,33 +196,85 @@ test.describe("System — Health tab @medium", () => {
await frigateApp.page.route(
(url) =>
url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_dismissed") === "true",
url.searchParams.get("include_hidden") === "true",
(route) =>
route.fulfill({
json: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
json: [EVENT_NOTICE, ACKNOWLEDGED_NOTICE, MUTED_NOTICE],
}),
);
await frigateApp.goto("/system#health");
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
const showDismissed = frigateApp.page.getByRole("switch", {
name: "Show dismissed",
const showHidden = frigateApp.page.getByRole("switch", {
name: "Show hidden",
});
await expect(showDismissed).toHaveAttribute("aria-checked", "false");
await showDismissed.click();
await expect(showHidden).toHaveAttribute("aria-checked", "false");
await showHidden.click();
// mobile opens the filter as a modal drawer, which hides the rows' roles
await frigateApp.page.keyboard.press("Escape");
const row = frigateApp.page.getByTestId(
const acknowledged = frigateApp.page.getByTestId(
"health-problem-notice:detector_stuck:coral",
);
await expect(acknowledged).toBeVisible({ timeout: 15_000 });
await expect(acknowledged).toContainText("Acknowledged");
await expect(
acknowledged.getByRole("button", { name: "Show again" }),
).toBeVisible();
await expect(
acknowledged.getByRole("button", { name: "Acknowledge" }),
).toHaveCount(0);
const muted = frigateApp.page.getByTestId(
"health-problem-notice:model_download_failed:yolo/model.onnx",
);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row).toContainText("Dismissed");
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
await expect(muted).toContainText("Muted");
await expect(muted.getByRole("button", { name: "Unmute" })).toBeVisible();
await expect(
muted.getByRole("button", { name: "Mute", exact: true }),
).toHaveCount(0);
await showDismissed.click();
await expect(row).toHaveCount(0);
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await showHidden.click();
await expect(muted).toHaveCount(0);
});
test("clear dismissed deletes the dismissed rows after confirming", async ({
test("unmute deletes the row's hidden state", async ({ frigateApp }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS, notices: [] });
await frigateApp.page.route(
(url) =>
url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_hidden") === "true",
(route) => route.fulfill({ json: [MUTED_NOTICE] }),
);
await frigateApp.page.route(
"**/api/notices/model_download_failed:yolo/model.onnx/hidden",
(route) => route.fulfill({ json: { success: true } }),
);
await frigateApp.goto("/system#health");
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
await frigateApp.page.keyboard.press("Escape");
const request = frigateApp.page.waitForRequest(
(req) =>
req
.url()
.endsWith(
"/api/notices/model_download_failed:yolo/model.onnx/hidden",
) && req.method() === "DELETE",
);
await frigateApp.page
.getByTestId(
"health-problem-notice:model_download_failed:yolo/model.onnx",
)
.getByRole("button", { name: "Unmute" })
.click({ timeout: 15_000 });
await request;
});
test("show all again unhides every row after confirming", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
@@ -203,29 +282,25 @@ test.describe("System — Health tab @medium", () => {
notices: [EVENT_NOTICE],
});
// the history loses its dismissed row once the DELETE lands
// the hidden list loses its muted row once the DELETE lands
let cleared = false;
await frigateApp.page.route(
(url) =>
url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_dismissed") === "true",
url.searchParams.get("include_hidden") === "true",
(route) =>
route.fulfill({
json: cleared
? [EVENT_NOTICE]
: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
json: cleared ? [EVENT_NOTICE] : [EVENT_NOTICE, MUTED_NOTICE],
}),
);
await frigateApp.page.route("**/api/notices/dismissed", (route) => {
await frigateApp.page.route("**/api/notices/hidden", (route) => {
cleared = true;
return route.fulfill({ json: { success: true } });
});
await frigateApp.goto("/system#health");
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await frigateApp.page
.getByRole("switch", { name: "Show dismissed" })
.click();
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
const row = frigateApp.page.getByTestId(
"health-problem-notice:model_download_failed:yolo/model.onnx",
);
@@ -233,23 +308,20 @@ test.describe("System — Health tab @medium", () => {
await frigateApp.page.keyboard.press("Escape");
await frigateApp.page
.getByRole("button", { name: "Clear dismissed" })
.getByRole("button", { name: "Show all again" })
.click();
const request = frigateApp.page.waitForRequest(
(req) =>
req.url().endsWith("/api/notices/dismissed") &&
req.method() === "DELETE",
req.url().endsWith("/api/notices/hidden") && req.method() === "DELETE",
);
await frigateApp.page
.getByRole("alertdialog")
.getByRole("button", { name: "Clear dismissed" })
.getByRole("button", { name: "Show all again" })
.click();
await request;
await expect(row).toHaveCount(0);
await expect(
frigateApp.page.getByText("No dismissed notices"),
).toBeVisible();
await expect(frigateApp.page.getByText("No hidden notices")).toBeVisible();
});
test("severity switches hide notices of that severity", async ({
@@ -290,7 +362,9 @@ test.describe("System — Health tab @medium", () => {
first_seen: start,
last_seen: start + 60,
count,
dismissed_at: null,
acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
});
await frigateApp.installDefaults({
stats: QUIET_STATS,
@@ -332,7 +406,9 @@ test.describe("System — Health tab @medium", () => {
first_seen: NOW - 600,
last_seen: NOW - 600,
count: 1,
dismissed_at: null,
acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
},
],
});
@@ -363,7 +439,9 @@ test.describe("System — Health tab @medium", () => {
first_seen: NOW - 600,
last_seen: NOW - 600,
count: 1,
dismissed_at: null,
acknowledgeable: true,
acknowledged_at: null,
muted_at: null,
},
],
});
@@ -695,7 +773,7 @@ test.describe("System — Health notices sources @medium", () => {
// the status bar shows a problem, so stats have loaded
await expect(
frigateApp.page.getByText("Front Door is offline"),
frigateApp.page.getByText("Recordings are being deleted"),
).toBeVisible({ timeout: 15_000 });
await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"),
@@ -1046,7 +1124,7 @@ test.describe("System — Health notices sources @medium", () => {
await expect(frigateApp.page).toHaveURL(/\/system#health/);
});
test("status bar counts undismissed notices next to the health text", async ({
test("status bar counts shown notices next to the health text", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
@@ -1089,41 +1167,61 @@ test.describe("System — Health notices sources @medium", () => {
await expect(frigateApp.page.getByText("System is healthy")).toHaveCount(0);
});
test("a config row can be dismissed", async ({ frigateApp }) => {
const id = "config:detect:fps-greater-than-five:camera.garage";
await frigateApp.installDefaults({
config: {
cameras: {
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
},
},
stats: QUIET_STATS,
});
// the slow detector warning is added before the offline error
const TWO_PROBLEM_STATS = {
detectors: { cpu: { inference_speed: 60 } },
cameras: { front_door: { camera_fps: 0 } },
};
// the list gains the dismissal after the POST so the refetch hides the row
let dismissed = false;
await frigateApp.page.route(`**/api/notices/${id}/dismiss`, (route) => {
dismissed = true;
return route.fulfill({ json: { success: true } });
});
await frigateApp.page.route("**/api/notices/dismissed_checks", (route) =>
route.fulfill({ json: dismissed ? [{ id, dismissed_at: NOW }] : [] }),
);
await frigateApp.goto("/system#health");
test("status bar collapses several problems behind the most severe", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS });
await frigateApp.goto("/");
const row = frigateApp.page.getByTestId(`health-problem-${id}`);
await expect(row).toBeVisible({ timeout: 15_000 });
const request = frigateApp.page.waitForRequest(
(req) =>
req.url().includes(`/api/notices/${id}/dismiss`) &&
req.method() === "POST",
);
await row.getByRole("button", { name: "Dismiss" }).click();
await request;
await expect(row).toHaveCount(0);
const summary = frigateApp.page.getByRole("button", {
name: "Front Door is offline +1",
});
await expect(summary).toBeVisible({ timeout: 15_000 });
await expect(frigateApp.page.getByText("Cpu is slow")).toHaveCount(0);
await summary.click();
const list = frigateApp.page.getByTestId("status-message-list");
await expect(list.getByRole("link")).toHaveText([
"Front Door is offline",
"Cpu is slow (60 ms)",
]);
await list.getByRole("link", { name: "Cpu is slow (60 ms)" }).click();
await expect(frigateApp.page).toHaveURL(/\/system#general/);
await expect(list).toHaveCount(0);
});
test("dismissed config rows move to the dismissed list", async ({
test("mobile status drawer stacks every problem", async ({ frigateApp }) => {
test.skip(!frigateApp.isMobile, "Mobile-only");
await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS });
await frigateApp.goto("/");
await frigateApp.page
.getByTestId("status-alert-trigger")
.click({ timeout: 15_000 });
const items = frigateApp.page
.getByTestId("status-message-list")
.getByRole("link");
await expect(items).toHaveText([
"Front Door is offline",
"Cpu is slow (60 ms)",
]);
const [first, second] = await Promise.all([
items.nth(0).boundingBox(),
items.nth(1).boundingBox(),
]);
expect(second!.y).toBeGreaterThan(first!.y);
});
test("a config row can be muted but not acknowledged", async ({
frigateApp,
}) => {
const id = "config:detect:fps-greater-than-five:camera.garage";
@@ -1134,12 +1232,49 @@ test.describe("System — Health notices sources @medium", () => {
},
},
stats: QUIET_STATS,
dismissedChecks: [{ id, dismissed_at: NOW - 120 }],
});
// the list gains the mute after the POST so the refetch hides the row
let muted = false;
await frigateApp.page.route(`**/api/notices/${id}/mute`, (route) => {
muted = true;
return route.fulfill({ json: { success: true } });
});
await frigateApp.page.route("**/api/notices/muted_checks", (route) =>
route.fulfill({ json: muted ? [{ id, muted_at: NOW }] : [] }),
);
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(`health-problem-${id}`);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount(
0,
);
const request = frigateApp.page.waitForRequest(
(req) =>
req.url().includes(`/api/notices/${id}/mute`) &&
req.method() === "POST",
);
await row.getByRole("button", { name: "Mute", exact: true }).click();
await request;
await expect(row).toHaveCount(0);
});
test("muted config rows move to the hidden list", async ({ frigateApp }) => {
const id = "config:detect:fps-greater-than-five:camera.garage";
await frigateApp.installDefaults({
config: {
cameras: {
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
},
},
stats: QUIET_STATS,
mutedChecks: [{ id, muted_at: NOW - 120 }],
});
await frigateApp.page.route(
(url) =>
url.pathname.endsWith("/api/notices") &&
url.searchParams.get("include_dismissed") === "true",
url.searchParams.get("include_hidden") === "true",
(route) => route.fulfill({ json: [] }),
);
await frigateApp.goto("/system#health");
@@ -1153,12 +1288,14 @@ test.describe("System — Health notices sources @medium", () => {
await expect(row).toHaveCount(0);
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
await frigateApp.page
.getByRole("switch", { name: "Show dismissed" })
.click();
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
await frigateApp.page.keyboard.press("Escape");
await expect(row).toBeVisible();
await expect(row).toContainText("Dismissed");
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
await expect(row).toContainText("Muted");
await expect(row.getByRole("button", { name: "Unmute" })).toBeVisible();
await expect(
row.getByRole("button", { name: "Mute", exact: true }),
).toHaveCount(0);
});
});
+24 -40
View File
@@ -69,7 +69,7 @@
"react-icons": "^5.7.0",
"react-konva": "^19.2.7",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.4",
"react-router-dom": "^6.30.6",
"react-swipeable": "^7.0.2",
"react-zoom-pan-pinch": "3.4.4",
"remark-gfm": "^4.0.0",
@@ -3042,6 +3042,15 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.23.4",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rjsf/core": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.0.tgz",
@@ -4813,19 +4822,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/copy-to-clipboard": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-4.0.2.tgz",
@@ -9502,41 +9498,35 @@
}
},
"node_modules/react-router": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"version": "6.30.6",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
"@remix-run/router": "1.23.4"
},
"engines": {
"node": ">=20.0.0"
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"version": "6.30.6",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.4"
"@remix-run/router": "1.23.4",
"react-router": "6.30.6"
},
"engines": {
"node": ">=20.0.0"
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/react-style-singleton": {
@@ -9851,12 +9841,6 @@
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+1 -1
View File
@@ -81,7 +81,7 @@
"react-icons": "^5.7.0",
"react-konva": "^19.2.7",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.4",
"react-router-dom": "^6.30.6",
"react-swipeable": "^7.0.2",
"react-zoom-pan-pinch": "3.4.4",
"remark-gfm": "^4.0.0",
@@ -1567,6 +1567,8 @@
"presetLabels": {
"preset-rpi-64-h264": "Raspberry Pi (H.264)",
"preset-rpi-64-h265": "Raspberry Pi (H.265)",
"preset-apple-silicon-h264": "Apple Silicon (H.264)",
"preset-apple-silicon-h265": "Apple Silicon (H.265)",
"preset-vaapi": "VAAPI (Intel/AMD GPU)",
"preset-intel-qsv-h264": "Intel QuickSync (H.264)",
"preset-intel-qsv-h265": "Intel QuickSync (H.265)",
+1
View File
@@ -48,6 +48,7 @@
"rkmpp": "RKMPP (Rockchip)",
"jetson": "NVIDIA Jetson",
"rpi": "V4L2 (Raspberry Pi)",
"apple-silicon": "Media engine (Apple Silicon)",
"none": "None (software decoding)"
}
},
+18 -10
View File
@@ -19,20 +19,26 @@
"notices": {
"title": "Notices",
"empty": "Your Frigate installation is healthy",
"dismiss": "Dismiss",
"acknowledge": "Acknowledge",
"acknowledgeHint": "Hide until this happens again",
"mute": "Mute",
"muteHint": "Never show this again",
"unmute": "Unmute",
"showAgain": "Show again",
"openSettings": "Open settings",
"openLink": "Open link",
"noMatches": "No notices match the filter",
"dismissedTitle": "Dismissed",
"noneDismissed": "No dismissed notices",
"clearDismissed": "Clear dismissed",
"clearDismissedTitle": "Clear dismissed notices?",
"clearDismissedDesc": "Every dismissed notice is deleted. Config and stream checks show again right away, and other notices return the next time they happen.",
"hiddenTitle": "Hidden",
"noneHidden": "No hidden notices",
"showAll": "Show all again",
"showAllTitle": "Show all hidden notices?",
"showAllDesc": "Every acknowledged and muted notice returns to the Notices list, including config and stream checks.",
"firstSeen_one": "First seen {{time}}",
"firstSeen_other": "First seen {{time}} · {{count}} times",
"dismissedAt": "Dismissed {{time}}",
"acknowledgedAt": "Acknowledged {{time}}",
"mutedAt": "Muted {{time}}",
"filter": {
"showDismissed": "Show dismissed",
"showHidden": "Show hidden",
"severity": "Severity",
"error": "Error",
"warning": "Warning",
@@ -42,6 +48,8 @@
"detector_stuck": "Detector {{detector}} was restarted after it stopped responding",
"model_download_failed": "Downloading {{file}} for {{model}} failed: {{error}}",
"skipped_detections": "Detection could not keep up and skipped {{pct}}% of frames for over a minute",
"ffmpeg_high_cpu": "FFmpeg CPU usage is high ({{cpu}}% average)",
"detect_high_cpu": "Detection CPU usage is high ({{cpu}}% average)",
"shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB",
"failed_login_one": "Failed login attempt for {{user}}",
"failed_login_other": "Failed login attempts for {{user}}",
@@ -324,12 +332,12 @@
},
"lastRefreshed": "Last refreshed: ",
"stats": {
"ffmpegHighCpuUsage": "{{camera}} has high FFmpeg CPU usage ({{ffmpegAvg}}%)",
"cameraSkippedDetections": "{{camera}} is skipping detection on {{pct}}% of frames",
"detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)",
"healthy": "System is healthy",
"systemNotices_one": "{{count}} system notice",
"systemNotices_other": "{{count}} system notices",
"moreMessages_one": "+{{count}}",
"moreMessages_other": "+{{count}}",
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)",
"cameraIsOffline": "{{camera}} is offline",
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
+78
View File
@@ -0,0 +1,78 @@
import { StatusMessage } from "@/context/statusbar-context";
import { cn } from "@/lib/utils";
import { ProblemSeverity } from "@/types/stats";
import { IoIosWarning } from "react-icons/io";
import { Link } from "react-router-dom";
const SEVERITY_COLOR: Record<ProblemSeverity, string> = {
error: "text-danger",
warning: "text-orange-400",
info: "text-selected",
};
type StatusMessageItemProps = {
message: StatusMessage;
className?: string;
onNavigate?: () => void;
};
/** One status bar message, a link when it has one. */
export function StatusMessageItem({
message,
className,
onNavigate,
}: StatusMessageItemProps) {
const content = (
<div
className={cn(
"flex items-center gap-2 text-sm",
message.link && "cursor-pointer hover:underline",
className,
)}
>
<IoIosWarning
className={cn("size-5 shrink-0", SEVERITY_COLOR[message.severity])}
/>
{message.text}
</div>
);
if (!message.link) {
return content;
}
return (
<Link to={message.link} onClick={onNavigate}>
{content}
</Link>
);
}
type StatusMessageListProps = {
messages: StatusMessage[];
className?: string;
onNavigate?: () => void;
};
/** Status bar messages stacked one per line. */
export default function StatusMessageList({
messages,
className,
onNavigate,
}: StatusMessageListProps) {
return (
<div
className={cn("flex flex-col gap-2", className)}
data-testid="status-message-list"
>
{messages.map((message, index) => (
// ids are unique only within a message key
<StatusMessageItem
key={`${index}:${message.id}`}
message={message}
onNavigate={onNavigate}
/>
))}
</div>
);
}
+55 -75
View File
@@ -1,20 +1,24 @@
import { useEmbeddingsReindexProgress } from "@/api/ws";
import { StatusMessage } from "@/context/statusbar-context";
import { useAutoFrigateStats } from "@/hooks/use-stats";
import useStatusMessages from "@/hooks/use-status-messages";
import StatusMessageList, {
StatusMessageItem,
} from "@/components/StatusMessageList";
import {
StatusBarMessagesContext,
StatusMessage,
} from "@/context/statusbar-context";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import StatusBarNotices from "@/components/health/StatusBarNotices";
import { cn } from "@/lib/utils";
import type { ProfilesApiResponse } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useContext, useEffect, useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { FaCheck } from "react-icons/fa";
import { IoIosWarning } from "react-icons/io";
import { MdCircle } from "react-icons/md";
import { Link } from "react-router-dom";
@@ -22,9 +26,7 @@ export default function Statusbar() {
const { t } = useTranslation(["views/system"]);
const isAdmin = useIsAdmin();
const { messages, addMessage, clearMessages } = useContext(
StatusBarMessagesContext,
)!;
const messages = useStatusMessages();
const stats = useAutoFrigateStats();
@@ -38,21 +40,6 @@ export default function Statusbar() {
return parseInt(systemCpu);
}, [stats]);
const { potentialProblems } = useStats(stats);
useEffect(() => {
clearMessages("stats");
potentialProblems.forEach((problem) => {
addMessage(
"stats",
problem.text,
problem.color,
undefined,
problem.relevantLink,
);
});
}, [potentialProblems, addMessage, clearMessages]);
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
const activeProfile = useMemo(() => {
@@ -68,28 +55,6 @@ export default function Statusbar() {
};
}, [profilesData]);
const { payload: reindexState } = useEmbeddingsReindexProgress();
useEffect(() => {
if (reindexState) {
if (reindexState.status == "indexing") {
clearMessages("embeddings-reindex");
addMessage(
"embeddings-reindex",
t("stats.reindexingEmbeddings", {
processed: Math.floor(
(reindexState.processed_objects / reindexState.total_objects) *
100,
),
}),
);
}
if (reindexState.status === "completed") {
clearMessages("embeddings-reindex");
}
}
}, [reindexState, addMessage, clearMessages, t]);
return (
<div className="absolute bottom-0 left-0 right-0 z-10 flex h-8 w-full items-center justify-between border-t border-secondary-highlight bg-background_alt px-4 dark:text-secondary-foreground">
<div className="flex h-full items-center gap-2">
@@ -187,42 +152,57 @@ export default function Statusbar() {
))}
</div>
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
{!isAdmin ? null : Object.entries(messages).length === 0 ? (
{!isAdmin ? null : messages.length === 0 ? (
<Link to="/system#health" className="flex items-center gap-2 text-sm">
<FaCheck className="size-3 text-green-500" />
{t("stats.healthy")}
</Link>
) : messages.length === 1 ? (
<StatusMessageItem
message={messages[0]}
className="whitespace-nowrap"
/>
) : (
Object.entries(messages).map(([key, messageArray]) => (
<div key={key} className="flex h-full items-center gap-2">
{messageArray.map(({ text, color, link }: StatusMessage) => {
const message = (
<div
key={text}
className={`flex items-center gap-2 whitespace-nowrap text-sm ${link ? "cursor-pointer hover:underline" : ""}`}
>
<IoIosWarning
className={`size-5 ${color || "text-danger"}`}
/>
{text}
</div>
);
if (link) {
return (
<Link key={text} to={link}>
{message}
</Link>
);
} else {
return message;
}
})}
</div>
))
<StatusMessagesPopover messages={messages} />
)}
{isAdmin && <StatusBarNotices />}
</div>
</div>
);
}
type StatusMessagesPopoverProps = {
messages: StatusMessage[];
};
/** The most severe message and a count of the rest, which open the full list. */
function StatusMessagesPopover({ messages }: StatusMessagesPopoverProps) {
const { t } = useTranslation(["views/system"]);
const [open, setOpen] = useState(false);
const [first, ...rest] = messages;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-2 text-sm hover:underline"
>
<StatusMessageItem
message={{ ...first, link: undefined }}
className="whitespace-nowrap"
/>
<span className="shrink-0 rounded-full bg-secondary px-1.5 text-xs text-secondary-foreground">
{t("stats.moreMessages", { count: rest.length })}
</span>
</button>
</PopoverTrigger>
<PopoverContent side="top" align="end" className="w-auto max-w-md">
<StatusMessageList
messages={messages}
onNavigate={() => setOpen(false)}
/>
</PopoverContent>
</Popover>
);
}
+46 -6
View File
@@ -2,7 +2,11 @@ import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FaTriangleExclamation } from "react-icons/fa6";
import {
LuBell,
LuBellOff,
LuCheck,
LuExternalLink,
LuEye,
LuInfo,
LuSlidersHorizontal,
LuX,
@@ -51,7 +55,13 @@ export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
problem.link ||
problem.docLink ||
problem.externalLink ||
problem.onDismiss;
problem.onAcknowledge ||
problem.onMute ||
problem.onUnhide;
const unhideLabel =
problem.hidden === "muted"
? t("health.notices.unmute")
: t("health.notices.showAgain");
return (
<div
@@ -150,16 +160,46 @@ export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
</Button>
</RowAction>
)}
{problem.onDismiss && (
<RowAction label={t("health.notices.dismiss")}>
{problem.onAcknowledge && (
<RowAction label={t("health.notices.acknowledgeHint")}>
<Button
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
aria-label={t("health.notices.dismiss")}
onClick={problem.onDismiss}
aria-label={t("health.notices.acknowledge")}
onClick={problem.onAcknowledge}
>
<LuX className="size-3.5" />
<LuCheck className="size-3.5" />
</Button>
</RowAction>
)}
{problem.onMute && (
<RowAction label={t("health.notices.muteHint")}>
<Button
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
aria-label={t("health.notices.mute")}
onClick={problem.onMute}
>
<LuBellOff className="size-3.5" />
</Button>
</RowAction>
)}
{problem.onUnhide && (
<RowAction label={unhideLabel}>
<Button
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
aria-label={unhideLabel}
onClick={problem.onUnhide}
>
{problem.hidden === "muted" ? (
<LuBell className="size-3.5" />
) : (
<LuEye className="size-3.5" />
)}
</Button>
</RowAction>
)}
@@ -24,7 +24,7 @@ export default function NoticeFilterButton({
const { t } = useTranslation(["views/system", "components/filter"]);
const [open, setOpen] = useState(false);
const active =
filter.showDismissed ||
filter.showHidden ||
filter.severities.length < DEFAULT_NOTICE_FILTER.severities.length;
const severityLabels: Record<HealthSeverity, string> = {
@@ -59,10 +59,10 @@ export default function NoticeFilterButton({
const content = (
<div className="space-y-3 p-4">
<FilterSwitch
label={t("health.notices.filter.showDismissed")}
isChecked={filter.showDismissed}
onCheckedChange={(showDismissed) =>
onFilterChange({ ...filter, showDismissed })
label={t("health.notices.filter.showHidden")}
isChecked={filter.showHidden}
onCheckedChange={(showHidden) =>
onFilterChange({ ...filter, showHidden })
}
/>
<DropdownMenuSeparator />
+17 -19
View File
@@ -23,9 +23,9 @@ type NoticesPaneProps = {
export default function NoticesPane({ filter }: NoticesPaneProps) {
const { t } = useTranslation(["views/system", "views/settings", "common"]);
const { problems, dismissed, loading, clearDismissed } = useHealthProblems(
const { problems, hidden, loading, unhideAll } = useHealthProblems(
t,
filter.showDismissed,
filter.showHidden,
);
const [confirmClear, setConfirmClear] = useState(false);
@@ -37,12 +37,10 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
[problems, filter.severities],
);
const shownDismissed = useMemo(
const shownHidden = useMemo(
() =>
dismissed?.filter((problem) =>
filter.severities.includes(problem.severity),
),
[dismissed, filter.severities],
hidden?.filter((problem) => filter.severities.includes(problem.severity)),
[hidden, filter.severities],
);
return (
@@ -70,32 +68,32 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
</div>
)}
</div>
{filter.showDismissed && (
{filter.showHidden && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<div className="text-sm text-muted-foreground">
{t("health.notices.dismissedTitle")}
{t("health.notices.hiddenTitle")}
</div>
{dismissed && dismissed.length > 0 && (
{hidden && hidden.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmClear(true)}
>
{t("health.notices.clearDismissed")}
{t("health.notices.showAll")}
</Button>
)}
</div>
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
{shownDismissed === undefined ? (
{shownHidden === undefined ? (
<Skeleton className="h-10 w-full" />
) : shownDismissed.length === 0 ? (
) : shownHidden.length === 0 ? (
<div className="px-1 py-2 text-sm text-muted-foreground">
{t("health.notices.noneDismissed")}
{t("health.notices.noneHidden")}
</div>
) : (
<div className="flex flex-col">
{shownDismissed.map((problem) => (
{shownHidden.map((problem) => (
<HealthProblemRow key={problem.id} problem={problem} />
))}
</div>
@@ -107,10 +105,10 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("health.notices.clearDismissedTitle")}
{t("health.notices.showAllTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("health.notices.clearDismissedDesc")}
{t("health.notices.showAllDesc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -119,9 +117,9 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={clearDismissed}
onClick={unhideAll}
>
{t("health.notices.clearDismissed")}
{t("health.notices.showAll")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useHealthProblems } from "@/hooks/use-health-problems";
/** The count of undismissed Notices rows, shown before the status bar's health text. */
/** The count of shown Notices rows, shown before the status bar's health text. */
export default function StatusBarNotices() {
const { t } = useTranslation(["views/system"]);
const { problems, loading } = useHealthProblems(t);
+10 -98
View File
@@ -1,30 +1,15 @@
import NavItem from "./NavItem";
import { IoIosWarning } from "react-icons/io";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import useSWR from "swr";
import { FrigateStats } from "@/types/stats";
import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws";
import {
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import useStats from "@/hooks/use-stats";
import { useLayoutEffect, useRef, useState } from "react";
import useStatusMessages from "@/hooks/use-status-messages";
import StatusMessageList from "../StatusMessageList";
import { useIsAdmin } from "@/hooks/use-is-admin";
import GeneralSettings from "../menu/GeneralSettings";
import useNavigation from "@/hooks/use-navigation";
import {
StatusBarMessagesContext,
StatusMessage,
} from "@/context/statusbar-context";
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { isPWA } from "@/utils/isPWA";
import { useTranslation } from "react-i18next";
function Bottombar() {
const navItems = useNavigation("secondary");
@@ -98,64 +83,12 @@ type StatusAlertNavProps = {
large?: boolean;
};
function StatusAlertNav({ className, large }: StatusAlertNavProps) {
const { t } = useTranslation(["views/system"]);
const { data: initialStats } = useSWR<FrigateStats>("stats", {
revalidateOnFocus: false,
});
const latestStats = useFrigateStats();
const { messages, addMessage, clearMessages } = useContext(
StatusBarMessagesContext,
)!;
const stats = useMemo(() => {
if (latestStats) {
return latestStats;
}
return initialStats;
}, [initialStats, latestStats]);
const { potentialProblems } = useStats(stats);
useEffect(() => {
clearMessages("stats");
potentialProblems.forEach((problem) => {
addMessage(
"stats",
problem.text,
problem.color,
undefined,
problem.relevantLink,
);
});
}, [potentialProblems, addMessage, clearMessages]);
const { payload: reindexState } = useEmbeddingsReindexProgress();
useEffect(() => {
if (reindexState) {
if (reindexState.status == "indexing") {
clearMessages("embeddings-reindex");
addMessage(
"embeddings-reindex",
t("stats.reindexingEmbeddings", {
processed: Math.floor(
(reindexState.processed_objects / reindexState.total_objects) *
100,
),
}),
);
}
if (reindexState.status === "completed") {
clearMessages("embeddings-reindex");
}
}
}, [reindexState, addMessage, clearMessages, t]);
const messages = useStatusMessages();
const isAdmin = useIsAdmin();
// problems link to admin-only pages
if (!isAdmin || !messages || Object.keys(messages).length === 0) {
if (!isAdmin || messages.length === 0) {
return;
}
@@ -163,6 +96,7 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) {
<Drawer>
<DrawerTrigger asChild>
<div
data-testid="status-alert-trigger"
className={cn(
"flex flex-col items-center justify-center p-2",
large && "size-12",
@@ -182,32 +116,10 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) {
className,
)}
>
<div className="scrollbar-container flex h-auto w-full flex-col items-center gap-2 overflow-y-auto overflow-x-hidden px-2 py-4">
{Object.entries(messages).map(([key, messageArray]) => (
<div key={key} className="flex w-full items-center gap-2">
{messageArray.map(({ id, text, color, link }: StatusMessage) => {
const message = (
<div key={id} className="flex items-center gap-2 text-xs">
<IoIosWarning
className={`size-5 ${color || "text-danger"}`}
/>
{text}
</div>
);
if (link) {
return (
<Link key={id} to={link}>
{message}
</Link>
);
} else {
return message;
}
})}
</div>
))}
</div>
<StatusMessageList
messages={messages}
className="scrollbar-container w-full overflow-y-auto overflow-x-hidden px-4 py-4"
/>
</DrawerContent>
</Drawer>
);
+2 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { LuFolderX } from "react-icons/lu";
import { isMobileOnly, isSafari } from "react-device-detect";
import { isMobileOnly } from "react-device-detect";
import { LuPause, LuPlay } from "react-icons/lu";
import {
DropdownMenu,
@@ -54,7 +54,7 @@ const CONTROLS_DEFAULT: VideoControls = {
snapshot: false,
fullscreen: false,
};
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
const PLAYBACK_RATE_DEFAULT = [0.5, 1, 2, 4, 8, 16];
const MIN_ITEMS_WRAP = 6;
type VideoControlsProps = {
+3 -2
View File
@@ -1,9 +1,10 @@
import { createContext } from "react";
import { ProblemSeverity } from "@/types/stats";
export type StatusMessage = {
id: string;
text: string;
color?: string;
severity: ProblemSeverity;
link?: string;
};
@@ -16,7 +17,7 @@ type StatusBarMessagesContextValue = {
addMessage: (
key: string,
message: string,
color?: string,
severity?: ProblemSeverity,
messageId?: string,
link?: string,
) => string | undefined;
+5 -4
View File
@@ -3,6 +3,7 @@ import {
StatusBarMessagesContext,
StatusMessagesState,
} from "@/context/statusbar-context";
import { ProblemSeverity } from "@/types/stats";
type StatusBarMessagesProviderProps = {
children: ReactNode;
@@ -19,21 +20,21 @@ export function StatusBarMessagesProvider({
(
key: string,
message: string,
color?: string,
severity: ProblemSeverity = "error",
messageId?: string,
link?: string,
) => {
if (!key || !message) return;
const id = messageId ?? Date.now().toString();
const msgColor = color ?? "text-danger";
// the text is the fallback id, so repeating a message replaces it
const id = messageId ?? message;
setMessagesState((prevMessages) => {
const existingMessages = prevMessages[key] || [];
// Check if a message with the same ID already exists
const messageIndex = existingMessages.findIndex((msg) => msg.id === id);
const newMessage = { id, text: message, color: msgColor, link };
const newMessage = { id, text: message, severity, link };
// If the message exists, replace it, otherwise add the new message
let updatedMessages;
+79 -55
View File
@@ -5,25 +5,25 @@ import useSWR from "swr";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useTimezone } from "@/hooks/use-date-utils";
import { useHealthChecks } from "@/hooks/use-health-checks";
import { useNotices } from "@/hooks/use-notices";
import { hiddenAt, useNotices } from "@/hooks/use-notices";
import { evaluateConfigHealth } from "@/utils/configHealth";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { sortHealthProblems } from "@/utils/healthSort";
import { streamHealth } from "@/utils/streamHealth";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import type { DismissedCheck, Notice } from "@/types/notice";
import type { MutedCheck, Notice } from "@/types/notice";
const EXTERNAL_LINK = /^https?:\/\//;
type HealthProblems = {
/** undismissed rows, most severe first */
/** shown rows, most severe first */
problems: HealthProblem[];
/** dismissed rows, most recently dismissed first; undefined until loaded */
dismissed?: HealthProblem[];
/** acknowledged and muted rows, most recently hidden first; undefined until loaded */
hidden?: HealthProblem[];
loading: boolean;
/** delete every dismissed row, so each can show again */
clearDismissed: () => Promise<void>;
/** show every hidden row again */
unhideAll: () => Promise<void>;
};
/**
@@ -34,7 +34,7 @@ type HealthProblems = {
*/
export function useHealthProblems(
t: TFunction,
showDismissed = false,
showHidden = false,
): HealthProblems {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
@@ -43,30 +43,40 @@ export function useHealthProblems(
const locale = useDateLocale();
const {
notices,
dismissed: dismissedNotices,
dismiss,
mutateDismissed,
} = useNotices(showDismissed);
const { data: dismissedChecks, mutate: mutateDismissedChecks } = useSWR<
DismissedCheck[]
>("notices/dismissed_checks");
hidden: hiddenNotices,
acknowledge,
mute,
unhide,
mutateHidden,
} = useNotices(showHidden);
const { data: mutedChecks, mutate: mutateMutedChecks } = useSWR<MutedCheck[]>(
"notices/muted_checks",
);
const {
stream: { results },
} = useHealthChecks();
const dismissCheck = useCallback(
const muteCheck = useCallback(
async (id: string) => {
await axios.post(`notices/${id}/dismiss`);
mutateDismissedChecks();
await axios.post(`notices/${id}/mute`);
mutateMutedChecks();
},
[mutateDismissedChecks],
[mutateMutedChecks],
);
const clearDismissed = useCallback(async () => {
await axios.delete("notices/dismissed");
mutateDismissed();
mutateDismissedChecks();
}, [mutateDismissed, mutateDismissedChecks]);
const unmuteCheck = useCallback(
async (id: string) => {
await axios.delete(`notices/${id}/hidden`);
mutateMutedChecks();
},
[mutateMutedChecks],
);
const unhideAll = useCallback(async () => {
await axios.delete("notices/hidden");
mutateHidden();
mutateMutedChecks();
}, [mutateHidden, mutateMutedChecks]);
const formatTime = useCallback(
(timestamp: number) =>
@@ -98,23 +108,37 @@ export function useHealthProblems(
count: notice.count,
}),
meta:
notice.dismissed_at === null
? t("health.notices.firstSeen", {
notice.muted_at !== null
? t("health.notices.mutedAt", {
ns: "views/system",
time: formatTime(notice.first_seen),
count: notice.count,
time: formatTime(notice.muted_at),
})
: t("health.notices.dismissedAt", {
ns: "views/system",
time: formatTime(notice.dismissed_at),
}),
: notice.acknowledged_at !== null
? t("health.notices.acknowledgedAt", {
ns: "views/system",
time: formatTime(notice.acknowledged_at),
})
: t("health.notices.firstSeen", {
ns: "views/system",
time: formatTime(notice.first_seen),
count: notice.count,
}),
link: external ? undefined : link,
externalLink: external ? link : undefined,
onDismiss:
notice.dismissed_at === null ? () => dismiss(notice.id) : undefined,
...(hiddenAt(notice) > 0
? {
hidden: notice.muted_at !== null ? "muted" : "acknowledged",
onUnhide: () => unhide(notice.id),
}
: {
onAcknowledge: notice.acknowledgeable
? () => acknowledge(notice.id)
: undefined,
onMute: () => mute(notice.id),
}),
};
},
[dismiss, formatTime, t],
[acknowledge, mute, unhide, formatTime, t],
);
const checks = useMemo<HealthProblem[]>(
@@ -128,12 +152,10 @@ export function useHealthProblems(
[config, results, t],
);
const dismissedAt = useMemo(
const mutedAt = useMemo(
() =>
new Map(
(dismissedChecks ?? []).map((check) => [check.id, check.dismissed_at]),
),
[dismissedChecks],
new Map((mutedChecks ?? []).map((check) => [check.id, check.muted_at])),
[mutedChecks],
);
const problems = useMemo(
@@ -141,27 +163,27 @@ export function useHealthProblems(
sortHealthProblems([
...(notices ?? []).map(noticeRow),
...checks
.filter((check) => !dismissedAt.has(check.id))
.filter((check) => !mutedAt.has(check.id))
.map((check) => ({
...check,
onDismiss: () => dismissCheck(check.id),
onMute: () => muteCheck(check.id),
})),
]),
[notices, noticeRow, checks, dismissedAt, dismissCheck],
[notices, noticeRow, checks, mutedAt, muteCheck],
);
const dismissed = useMemo(() => {
if (!showDismissed || dismissedNotices === undefined) {
const hidden = useMemo(() => {
if (!showHidden || hiddenNotices === undefined) {
return undefined;
}
const rows = [
...dismissedNotices.map((notice) => ({
at: notice.dismissed_at ?? 0,
...hiddenNotices.map((notice) => ({
at: hiddenAt(notice),
row: noticeRow(notice),
})),
...checks.flatMap((check) => {
const at = dismissedAt.get(check.id);
const at = mutedAt.get(check.id);
return at === undefined
? []
@@ -170,10 +192,12 @@ export function useHealthProblems(
at,
row: {
...check,
meta: t("health.notices.dismissedAt", {
meta: t("health.notices.mutedAt", {
ns: "views/system",
time: formatTime(at),
}),
hidden: "muted" as const,
onUnhide: () => unmuteCheck(check.id),
},
},
];
@@ -182,17 +206,17 @@ export function useHealthProblems(
return rows.sort((a, b) => b.at - a.at).map(({ row }) => row);
}, [
showDismissed,
dismissedNotices,
showHidden,
hiddenNotices,
noticeRow,
checks,
dismissedAt,
mutedAt,
formatTime,
unmuteCheck,
t,
]);
const loading =
notices === undefined || dismissedChecks === undefined || !config;
const loading = notices === undefined || mutedChecks === undefined || !config;
return { problems, dismissed, loading, clearDismissed };
return { problems, hidden, loading, unhideAll };
}
+38 -22
View File
@@ -4,19 +4,22 @@ import useSWR from "swr";
import { useWs } from "@/api/ws";
import type { Notice } from "@/types/notice";
/** When a hidden notice was acknowledged or muted. */
export function hiddenAt(notice: Notice): number {
return notice.muted_at ?? notice.acknowledged_at ?? 0;
}
/**
* Active notices come from a REST snapshot, then from every `notices`
* websocket payload. Dismissed notices are fetched only while the history is
* shown, and again when the active list changes or the tab regains focus. A
* purge in another tab leaves the active list unchanged, so only focus
* catches it.
* websocket payload. Hidden notices are fetched only while the hidden list is
* shown, and again when the active list changes or the tab regains focus.
*/
export function useNotices(showDismissed: boolean) {
export function useNotices(showHidden: boolean) {
const { data: initial, mutate } = useSWR<Notice[]>("notices", {
revalidateOnFocus: false,
});
const { data: history, mutate: mutateHistory } = useSWR<Notice[]>(
showDismissed ? ["notices", { include_dismissed: true }] : null,
const { data: all, mutate: mutateHidden } = useSWR<Notice[]>(
showHidden ? ["notices", { include_hidden: true }] : null,
);
const {
value: { payload },
@@ -30,31 +33,44 @@ export function useNotices(showDismissed: boolean) {
[payload],
);
// once a websocket frame has arrived it is the source of truth; a dismiss
// still shows up because the registry publishes a new frame after it
// once a websocket frame has arrived it is the source of truth; every
// acknowledge, mute, and unhide publishes a new frame
const notices = live ?? initial;
// refetch the history whenever the active list changes; SWR ignores the
// call while the history is hidden
// refetch the hidden list whenever the active list changes; SWR ignores the
// call while the hidden list is not shown
useEffect(() => {
mutateHistory();
}, [live, mutateHistory]);
mutateHidden();
}, [live, mutateHidden]);
const dismissed = useMemo(
const hidden = useMemo(
() =>
history
?.filter((notice) => notice.dismissed_at !== null)
.sort((a, b) => (b.dismissed_at ?? 0) - (a.dismissed_at ?? 0)),
[history],
all
?.filter((notice) => hiddenAt(notice) > 0)
.sort((a, b) => hiddenAt(b) - hiddenAt(a)),
[all],
);
const dismiss = useCallback(
async (id: string) => {
await axios.post(`notices/${id}/dismiss`);
const act = useCallback(
async (request: Promise<unknown>) => {
await request;
mutate();
},
[mutate],
);
return { notices, dismissed, dismiss, mutateDismissed: mutateHistory };
const acknowledge = useCallback(
(id: string) => act(axios.post(`notices/${id}/acknowledge`)),
[act],
);
const mute = useCallback(
(id: string) => act(axios.post(`notices/${id}/mute`)),
[act],
);
const unhide = useCallback(
(id: string) => act(axios.delete(`notices/${id}/hidden`)),
[act],
);
return { notices, hidden, acknowledge, mute, unhide, mutateHidden };
}
+3 -48
View File
@@ -1,9 +1,5 @@
import { FrigateConfig } from "@/types/frigateConfig";
import {
CameraDetectThreshold,
CameraFfmpegThreshold,
InferenceThreshold,
} from "@/types/graph";
import { InferenceThreshold } from "@/types/graph";
import { FrigateStats, PotentialProblem, ProblemSeverity } from "@/types/stats";
import { useMemo } from "react";
import useSWR from "swr";
@@ -15,20 +11,12 @@ import { useIsAdmin } from "./use-is-admin";
import { useTranslation } from "react-i18next";
// the status bar has always rendered these exact classes; keep them byte for
// byte so its output does not change
const SEVERITY_COLOR: Record<ProblemSeverity, string> = {
error: "text-danger",
warning: "text-orange-400",
info: "text-selected",
};
function problem(
severity: ProblemSeverity,
text: string,
relevantLink?: string,
): PotentialProblem {
return { text, severity, color: SEVERITY_COLOR[severity], relevantLink };
return { text, severity, relevantLink };
}
// matches SKIPPED_DETECTIONS_PCT in frigate/stats/emitter.py
@@ -122,20 +110,13 @@ export default function useStats(stats: FrigateStats | undefined) {
}
});
// check camera cpu usages
// check for skipped detections
Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
// Skip replay cameras
if (isReplayCamera(name)) {
return;
}
const ffmpegAvg = parseFloat(
memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average,
);
const detectAvg = parseFloat(
memoizedStats["cpu_usages"][cam["pid"]]?.cpu_average,
);
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
if (
@@ -153,32 +134,6 @@ export default function useStats(stats: FrigateStats | undefined) {
),
);
}
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
problems.push(
problem(
"error",
t("stats.ffmpegHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
ffmpegAvg,
}),
"/system#cameras",
),
);
}
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
problems.push(
problem(
"error",
t("stats.detectHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
detectAvg,
}),
"/system#cameras",
),
);
}
});
// Add message if debug replay is active
+74
View File
@@ -0,0 +1,74 @@
import { useEmbeddingsReindexProgress } from "@/api/ws";
import {
StatusBarMessagesContext,
StatusMessage,
} from "@/context/statusbar-context";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import { ProblemSeverity } from "@/types/stats";
import { useContext, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
const SEVERITY_ORDER: Record<ProblemSeverity, number> = {
error: 0,
warning: 1,
info: 2,
};
/**
* Publishes the stats problems and reindex progress to the status bar, then
* returns every status bar message, most severe first.
*/
export default function useStatusMessages(): StatusMessage[] {
const { t } = useTranslation(["views/system"]);
const { messages, addMessage, clearMessages } = useContext(
StatusBarMessagesContext,
)!;
const stats = useAutoFrigateStats();
const { potentialProblems } = useStats(stats);
useEffect(() => {
clearMessages("stats");
potentialProblems.forEach((problem) => {
addMessage(
"stats",
problem.text,
problem.severity,
undefined,
problem.relevantLink,
);
});
}, [potentialProblems, addMessage, clearMessages]);
const { payload: reindexState } = useEmbeddingsReindexProgress();
useEffect(() => {
if (reindexState) {
if (reindexState.status == "indexing") {
clearMessages("embeddings-reindex");
addMessage(
"embeddings-reindex",
t("stats.reindexingEmbeddings", {
processed: Math.floor(
(reindexState.processed_objects / reindexState.total_objects) *
100,
),
}),
);
}
if (reindexState.status === "completed") {
clearMessages("embeddings-reindex");
}
}
}, [reindexState, addMessage, clearMessages, t]);
return useMemo(
() =>
Object.values(messages)
.flat()
.sort(
(a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],
),
[messages],
);
}
+10 -3
View File
@@ -27,16 +27,23 @@ export type HealthProblem = {
externalLink?: string;
/** render with a spinner instead of the severity icon (stream check running) */
pending?: boolean;
onDismiss?: () => void;
/** why a hidden row is hidden */
hidden?: "acknowledged" | "muted";
/** hide until the next occurrence; only for kinds that repeat */
onAcknowledge?: () => void;
/** hide for good */
onMute?: () => void;
/** show a hidden row again */
onUnhide?: () => void;
};
/** What the Health tab's filter shows. */
export type NoticeFilter = {
showDismissed: boolean;
showHidden: boolean;
severities: HealthSeverity[];
};
export const DEFAULT_NOTICE_FILTER: NoticeFilter = {
showDismissed: false,
showHidden: false,
severities: ["error", "warning", "info"],
};
+9 -4
View File
@@ -13,11 +13,16 @@ export type Notice = {
first_seen: number;
last_seen: number;
count: number;
dismissed_at: number | null;
/** whether the kind repeats, so acknowledging it can hide it until next time */
acknowledgeable: boolean;
/** hidden until the next occurrence */
acknowledged_at: number | null;
/** hidden for good */
muted_at: number | null;
};
/** a config or stream check row an admin dismissed */
export type DismissedCheck = {
/** a config or stream check row an admin muted */
export type MutedCheck = {
id: string;
dismissed_at: number;
muted_at: number;
};
-1
View File
@@ -129,7 +129,6 @@ export type ProblemSeverity = "error" | "warning" | "info";
export type PotentialProblem = {
text: string;
severity: ProblemSeverity;
color: string;
relevantLink?: string;
};
+10 -1
View File
@@ -256,7 +256,13 @@ export function detectionRows({
// ------------------------------------------------------------------ hwaccel
export type HwaccelFamilyKey =
"nvidia" | "vaapi" | "intel-qsv" | "rkmpp" | "jetson" | "rpi";
| "nvidia"
| "vaapi"
| "intel-qsv"
| "rkmpp"
| "jetson"
| "rpi"
| "apple-silicon";
export type HwaccelClass =
| { kind: "none" }
@@ -270,6 +276,7 @@ const PRESET_FAMILIES: [string, HwaccelFamilyKey][] = [
["preset-rk", "rkmpp"],
["preset-jetson", "jetson"],
["preset-rpi", "rpi"],
["preset-apple-silicon", "apple-silicon"],
];
export function hwaccelFamily(value: string | string[]): HwaccelClass {
@@ -294,6 +301,8 @@ const FAMILY_VENDORS: Record<HwaccelFamilyKey, GpuVendor[]> = {
vaapi: ["intel", "amd"],
rkmpp: ["rockchip"],
rpi: ["rpi"],
// lighter reports no decoder usage
"apple-silicon": [],
};
function decoderUsage(
+1 -2
View File
@@ -17,7 +17,6 @@ import {
useUserPersistence,
deleteUserNamespacedKey,
} from "@/hooks/use-user-persistence";
import { isSafari } from "react-device-detect";
import {
Select,
SelectContent,
@@ -132,7 +131,7 @@ export default function UiSettingsView() {
const { auth } = useContext(AuthContext);
const username = auth?.user?.username;
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
const PLAYBACK_RATE_DEFAULT = [0.5, 1, 2, 4, 8, 16];
const clearStoredLayouts = useCallback(() => {
if (!config) {