Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 1deac13ea7 Bump the web-minor-patch group in /web with 3 updates
Bumps the web-minor-patch group in /web with 3 updates: [react-zoom-pan-pinch](https://github.com/BetterTyped/react-zoom-pan-pinch), [tailwind-merge](https://github.com/dcastil/tailwind-merge/tree/HEAD/packages/tailwind-merge) and [monaco-editor](https://github.com/microsoft/monaco-editor).


Updates `react-zoom-pan-pinch` from 3.4.4 to 3.7.0
- [Release notes](https://github.com/BetterTyped/react-zoom-pan-pinch/releases)
- [Changelog](https://github.com/BetterTyped/react-zoom-pan-pinch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BetterTyped/react-zoom-pan-pinch/compare/v3.4.4...v3.7.0)

Updates `tailwind-merge` from 2.4.0 to 2.6.1
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/commits/v2.6.1/packages/tailwind-merge)

Updates `monaco-editor` from 0.52.2 to 0.54.0
- [Release notes](https://github.com/microsoft/monaco-editor/releases)
- [Changelog](https://github.com/microsoft/monaco-editor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/microsoft/monaco-editor/compare/v0.52.2...v0.54.0)

---
updated-dependencies:
- dependency-name: react-zoom-pan-pinch
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: tailwind-merge
  dependency-version: 2.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: monaco-editor
  dependency-version: 0.54.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-10 20:16:22 +00:00
Josh HawkinsandGitHub 2cb03f442c Group dependabot updates (#24624)
CI / AMD64 Extra Build (push) Blocked by required conditions
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 / 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
* group dependabot updates and hold known-bad versions

Dependabot opened a separate PR for every bump, refilled the list with security PRs right after each merge, and kept proposing versions known to break Frigate.

- Group minor and patch updates and security updates into one PR per ecosystem, with separate groups for packages that only resolve together
- Move every version hold into ignore rules, each with its reason
- Switch to weekly updates with a 3-day cooldown
- Turn off version updates for docker/tensorrt, since no pull request check builds those images
- Drop target-branch, which kept these options from applying to security updates, and the docker entry, which never opened a PR
- Scan the setup composite action, and bump change-string-case, setup-qemu, setup-buildx and login-action

* group github actions security updates
2026-10-10 15:08:01 -05:00
Josh HawkinsandGitHub 79177db969 Update deps (#24623)
* bump backend and frontend dependencies

Bumps every outdated dependency that passed testing, with the code changes each one needs.

- peewee 4.5 and peewee_migrate 2.3: use SqliteDatabase in place of the removed SqliteExtDatabase, and fix types for peewee's bundled stubs
- mypy 2.4.0: remove unused type ignores and add the annotations mypy 2 needs
- pandas 3.0: convert the motion activity index with as_unit("s"), because pandas 3 infers second resolution for whole-second timestamps and the old // 10**9 returned 1
- transformers 5.19: load Jina tokenizers from the saved directory with trust_remote_code off and use CLIPImageProcessor, since v5 removed CLIPFeatureExtractor and remote code pulled in torch
- tensorflow-cpu 2.21: pin protobuf to 7.36 in the TensorRT amd64 image, because the old 3.20.3 pin was installed over TensorFlow and broke its import
- pydantic 2.13 and google-genai 2.29: regenerate the API spec
- ruff 0.16: set an explicit lint select, since 0.16 widened the default rules
- react-router-dom 7: set useTransitions={false} on BrowserRouter to keep v6 update timing, which useSearchEffect relies on
- typescript 6: drop baseUrl and add node types in tsconfig
- radix: update to the latest release and remove the slot patch, which upstream now includes
- apexcharts 7.8: guard the tooltip formatter against null values
- cryptography 50, joserfc 1.7, pywebpush 2.5, openai 3.26, aiohttp, uvicorn, onvif-zeep-async, scipy, framer-motion 14, react-day-picker 10 and other minor and patch bumps

* silence httpx2 request logging

* redownload an incomplete jina tokenizer

An interrupted tokenizer download left the Hugging Face cache directory without the saved tokenizer files, and since the download is skipped whenever that directory exists, every restart failed to load the tokenizer. The Jina constructors now remove a tokenizer directory that has no tokenizer_config.json so the download runs again.
2026-10-10 15:01:33 -05:00
Nicolas MowenandGitHub e8e6326d37 Create a notice when frames are backed up in the tracked object process (#24619)
* Create a notice when frames are backed up in the tracked object processor

* Launch notice in thread to avoid more delay
2026-10-10 11:59:15 -05:00
Nicolas MowenandGitHub 01f3369659 Ollama image improvements (#24605)
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
* Support embeddinggemma for ollama

* Refactor docs

* Allow Ollama to probe cost of image tokens

* Cleanup
2026-10-09 08:01:09 -06:00
f1d1748fb3 Fix Birdseye rapid camera switching when max_cameras is set (#24591)
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
* Fix Birdseye rapid camera switching in single-camera view (#10845)

Add a configurable min_camera_hold (default 5s) to prevent Birdseye from
rapidly flipping between cameras when multiple have simultaneous activity.
Also fix the max_cameras cooldown which was bypassed whenever more cameras
were active than the configured limit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove min_camera_hold config, use hardcoded CAMERA_HOLD_SECONDS constant

Replace the configurable min_camera_hold field with a module-level
CAMERA_HOLD_SECONDS = 5 constant to keep behavior simple and avoid
adding config complexity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix max_cameras hold blocking count changes, add count-increase test

The >= guard could prevent cameras from appearing when the displayed
count was below max_cameras. Now the hold only applies when the limited
count matches the currently displayed count, so count changes are
always immediate. Adds a dedicated test for this case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Apply reviewer feedback: simplify max_cameras hold, remove single-camera hold

- Use hawkeye217's suggested condition for max_cameras cooldown:
  only hold when currently showing exactly max_cameras and there are
  at least max_cameras active
- Remove single-camera hold entirely — without max_cameras set,
  the hold only triggers when one camera expires as another activates,
  keeping the inactive camera visible unnecessarily
- Remove last_layout_change_time (no longer used)
- Remove single-camera hold tests (feature removed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-09 06:46:16 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f084e6acc7 Bump brace-expansion from 1.1.18 to 1.1.21 in /docs (#24596)
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
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.18 to 1.1.21.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.18...v1.1.21)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-08 07:49:16 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a3ea529eb5 Bump source-map-js from 1.2.1 to 1.2.2 in /docs (#24595)
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-08 07:48:52 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c53adf1f72 Bump compression from 1.8.1 to 1.8.2 in /docs (#24585)
Bumps [compression](https://github.com/expressjs/compression) from 1.8.1 to 1.8.2.
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

---
updated-dependencies:
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-08 07:45:14 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
528967a783 Bump source-map-js from 1.2.1 to 1.2.2 in /web (#24583)
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-08 07:44:55 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
02dd4df3d9 Bump shell-quote from 1.10.0 to 1.12.0 in /docs (#24582)
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.10.0 to 1.12.0.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.10.0...v1.12.0)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.12.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-08 07:44:34 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fb67d735b6 Bump proxy-addr from 2.0.7 to 2.0.8 in /docs (#24584)
Bumps [proxy-addr](https://github.com/jshttp/proxy-addr) from 2.0.7 to 2.0.8.
- [Release notes](https://github.com/jshttp/proxy-addr/releases)
- [Changelog](https://github.com/jshttp/proxy-addr/blob/master/HISTORY.md)
- [Commits](https://github.com/jshttp/proxy-addr/compare/v2.0.7...v2.0.8)

---
updated-dependencies:
- dependency-name: proxy-addr
  dependency-version: 2.0.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-08 07:44:13 -05:00
lightsandGitHub 25f8568c66 docs: hardware page still says Maxwell (CC 5.0) GPUs are supported (#24493)
* docs: hardware page still says Maxwell (CC 5.0) GPUs are supported

Signed-off-by: lights <115397533+lightsabit@users.noreply.github.com>

* docs: replace em dash in Maxwell GPU note (greptile suggestion)

* docs: state oldest supported GPU generation, link community workaround

---------

Signed-off-by: lights <115397533+lightsabit@users.noreply.github.com>
2026-10-07 05:40:23 -06:00
Josh HawkinsandGitHub f1c4a9f769 Improve Reolink camera wizard detection (#24570)
CI / AMD64 Build (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 / AMD64 Smoke Test (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* try http-flv first for reolink cameras above 5MP in the camera wizard

The wizard picked RTSP for every Reolink above 5MP and then warned that RTSP isn't recommended, telling users to enable HTTP and restart, which just produced the same result. It now tries the http-flv stream first for those cameras and falls back to RTSP when the stream doesn't probe, since only the newer models serve H.265 over enhanced FLV. The RTSP warning is skipped when the camera's resolution made RTSP a valid choice.

http-flv no longer needs go2rtc's ffmpeg module, so the compatibility mode warning for Reolink HTTP streams is removed and the docs drop the `ffmpeg:` prefix from http-flv sources that have no transcoding modifiers.

* add reolink tests

* keep using ffmpeg module
2026-10-06 15:42:21 -06:00
Josh HawkinsandGitHub 28d8f5bbfa wake the mqtt worker when a publish is queued (#24580)
publish() only queued the message, and the worker was blocked waiting on the broker socket for up to a second, so on a quiet connection messages went out up to 1s late. A socketpair registered in the worker's selector now interrupts the wait when a publish is queued or stop() is called.
2026-10-06 15:20:07 -06:00
Josh HawkinsandGitHub 8b89b79ef5 Deps updates (#24581)
* update fastapi and unpin starlette

fastapi 0.116 capped starlette below 0.49, which kept us on a version with several open advisories, the earliest of which is fixed in 0.49.1. fastapi moves to 0.142 and the starlette pin is removed so the latest (1.7.0 today) is pulled in.

fastapi 0.137 and later stopped flattening included routers into app.routes, so the spec generator found no routes to classify and every endpoint failed validation. The generator now reads routes from the routers directly. The spec is regenerated on the new versions.

* pin starlette
2026-10-06 15:19:31 -06:00
78 changed files with 2198 additions and 1570 deletions
+4 -4
View File
@@ -34,15 +34,15 @@ runs:
run: sudo systemctl start docker
shell: bash
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v5
uses: ASzc/change-string-case-action@v8
with:
string: ${{ github.repository }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to the Container registry
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
+139 -22
View File
@@ -1,40 +1,157 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
# Local composite actions are only scanned when their directory is listed
directories:
- "/"
- "/.github/actions/setup"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
- package-ecosystem: "docker"
directory: "/docker"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
actions:
patterns: ["*"]
actions-security:
applies-to: security-updates
patterns: ["*"]
- package-ecosystem: "pip"
directory: "/docker/main"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
# Sets that fail pip resolution unless they move together
peewee:
patterns: ["peewee", "peewee-migrate"]
crypto:
patterns: ["cryptography", "joserfc", "pywebpush", "py-vapid"]
pydantic-genai:
patterns: ["pydantic", "google-genai"]
pip-main-minor-patch:
patterns: ["*"]
update-types: ["minor", "patch"]
pip-main-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# numpy 2.5 needs Python 3.12; norfair, tflite_runtime and rknn-toolkit2
# cap numpy below 2
- dependency-name: "numpy"
versions: [">=2"]
# Every OpenCV wheel from 4.12 requires numpy 2
- dependency-name: "opencv-python-headless"
versions: [">=4.12"]
- dependency-name: "opencv-contrib-python"
versions: [">=4.12"]
# aarch64 only. Rockchip rknn-toolkit2 caps protobuf at 4.25.4 and
# TensorFlow 2.21 needs protobuf 6.31 or newer.
- dependency-name: "tensorflow"
versions: [">=2.20"]
# Needs Python 3.12 and numpy 2
- dependency-name: "librosa"
versions: [">=1.0"]
# Needs Python 3.12
- dependency-name: "scipy"
versions: [">=1.18"]
# Newer stubs report false errors against peewee 4
- dependency-name: "types-peewee"
versions: [">=4.1"]
# sdist only and imported by HailoRT; needs a Hailo device to test
- dependency-name: "netifaces"
versions: [">=0.11"]
- package-ecosystem: "pip"
directory: "/docker/tensorrt"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
interval: weekly
# No pull request check builds these images, so version updates are off.
# Security updates still open.
open-pull-requests-limit: 0
groups:
pip-trt-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# onnx 1.17 and newer write a newer IR and opset than TensorRT 8.6 on
# JetPack 6 is known to parse
- dependency-name: "onnx"
versions: [">=1.17"]
# 1.27 moved to CUDA 13, and the image ships the CUDA 12.8 wheel set
- dependency-name: "onnxruntime-gpu"
versions: [">=1.27"]
# Hand-matched CUDA 12.8 set (2c9a25e67); move as a block
- dependency-name: "nvidia-*"
# Must match the JetPack CUDA runtime
- dependency-name: "cuda-python"
versions: [">=12.9"]
- package-ecosystem: "npm"
directory: "/web"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
radix:
patterns: ["@radix-ui/*"]
rjsf:
patterns: ["@rjsf/*"]
web-minor-patch:
patterns: ["*"]
update-types: ["minor", "patch"]
web-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# typescript-eslint peers typescript below 6.1
- dependency-name: "typescript"
versions: [">=6.1"]
# Tailwind 4 is a config rewrite; scrollbar 4 and merge 3 depend on it
- dependency-name: "tailwindcss"
update-types: ["version-update:semver-major"]
- dependency-name: "tailwind-scrollbar"
update-types: ["version-update:semver-major"]
- dependency-name: "tailwind-merge"
update-types: ["version-update:semver-major"]
# Intermittent failure in the UI settings import spec, cause not found
- dependency-name: "zod"
update-types: ["version-update:semver-major"]
# Needs an on-device pinch test before moving
- dependency-name: "react-zoom-pan-pinch"
update-types: ["version-update:semver-major"]
# Build fails until monaco-worker-manager releases its PR 4
- dependency-name: "monaco-editor"
versions: [">=0.55.0"]
# Later versions need Node 22 and extract false keys
- dependency-name: "i18next-cli"
versions: [">1.5.11"]
# 6.11.0 refills the saved model in the GenAI settings form
- dependency-name: "@rjsf/*"
versions: ["6.11.0"]
- package-ecosystem: "npm"
directory: "/docs"
schedule:
interval: daily
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
allow:
- dependency-name: "@docusaurus/*"
open-pull-requests-limit: 10
target-branch: dev
groups:
docusaurus:
patterns: ["@docusaurus/*"]
docs-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# A Docusaurus major needs the openapi plugin and theme moved in the
# same commit
- dependency-name: "@docusaurus/*"
update-types: ["version-update:semver-major"]
+1 -1
View File
@@ -586,7 +586,7 @@ jobs:
- arm64_build
steps:
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v6
uses: ASzc/change-string-case-action@v8
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
with:
persist-credentials: false
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v6
uses: ASzc/change-string-case-action@v8
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
+1 -1
View File
@@ -1,4 +1,4 @@
ruff == 0.15.20
ruff == 0.16.10
# types
types-peewee == 4.0.*
+31 -31
View File
@@ -1,66 +1,66 @@
aiofiles == 25.1.*
click == 8.5.*
# FastAPI
aiohttp == 3.12.*
starlette == 0.47.*
aiohttp == 3.14.*
starlette == 1.7.*
starlette-context == 0.5.*
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
uvicorn == 0.52.*
fastapi[standard-no-fastapi-cloud-cli] == 0.142.*
uvicorn == 0.54.*
slowapi == 0.1.*
joserfc == 1.6.*
cryptography == 46.0.*
joserfc == 1.7.*
cryptography == 50.0.*
pathvalidate == 3.3.*
markupsafe == 3.0.*
python-multipart == 0.0.31
python-multipart == 0.0.32
# Classification Model Training
tensorflow == 2.19.* ; platform_machine == 'aarch64'
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
tensorflow-cpu == 2.21.* ; platform_machine == 'x86_64'
# General
mypy == 1.6.1
onvif-zeep-async == 4.0.*
mypy == 2.4.0
onvif-zeep-async == 4.3.*
paho-mqtt == 2.1.*
pandas == 2.2.*
peewee == 3.17.*
peewee_migrate == 1.14.*
psutil == 7.1.*
pydantic == 2.10.*
pandas == 3.0.*
peewee == 4.5.*
peewee_migrate == 2.3.*
psutil == 7.2.*
pydantic == 2.13.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
pytz == 2025.*
pyzmq == 27.1.*
pytz == 2026.*
pyzmq == 27.2.*
ruamel.yaml == 0.19.*
tzlocal == 5.2
requests == 2.33.*
types-requests == 2.32.*
tzlocal == 5.4.4
requests == 2.34.*
types-requests == 2.33.*
norfair == 2.3.*
setproctitle == 1.3.*
ws4py == 0.5.*
ws4py == 0.6.*
unidecode == 1.4.*
titlecase == 2.4.*
# Image Manipulation
numpy == 1.26.*
opencv-python-headless == 4.11.0.*
opencv-contrib-python == 4.11.0.*
scipy == 1.16.*
scipy == 1.17.*
# OpenVino & ONNX
openvino == 2025.4.*
onnxruntime == 1.30.*
# Embeddings
transformers == 4.45.*
transformers == 5.19.*
# Generative AI
google-genai == 1.58.*
google-genai == 2.29.*
ollama == 0.6.*
openai == 1.65.*
openai == 3.26.*
# push notifications
py-vapid == 1.9.4
pywebpush == 2.0.*
pywebpush == 2.5.*
# alpr
pyclipper == 1.4.*
shapely == 2.0.*
rapidfuzz==3.12.*
shapely == 2.2.*
rapidfuzz==3.14.*
# HailoRT
argcomplete==3.7.*
contextlib2==0.6.*
future==0.18.*
contextlib2==21.6.*
future==1.0.*
netaddr==1.3.*
netifaces==0.10.*
prometheus-client == 0.26.*
@@ -71,6 +71,6 @@ tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.
sherpa-onnx==1.13.*
faster-whisper==1.2.*
librosa==0.11.*
soundfile==0.13.*
soundfile==0.14.*
# Memory profiling
memray == 1.20.*
+1 -1
View File
@@ -14,4 +14,4 @@ nvidia-nccl-cu12==2.26.2.post1; platform_machine == 'x86_64'
nvidia-nvjitlink-cu12==12.8.93; platform_machine == 'x86_64'
onnx==1.16.*; platform_machine == 'x86_64'
onnxruntime-gpu==1.24.*; platform_machine == 'x86_64'
protobuf==3.20.3; platform_machine == 'x86_64'
protobuf==7.36.*; platform_machine == 'x86_64'
+1 -1
View File
@@ -289,7 +289,7 @@ The only field that is valid at the camera level is `enabled`. In particular `mo
#### GenAI Provider
Frigate can send audio to a GenAI provider for transcription when that provider has the `transcribe` role. This is useful if you already run a GenAI provider, or if you do not have the CPU/GPU headroom for a local whisper model. Supported providers are **OpenAI**, **Azure OpenAI**, **Gemini**, and **llama.cpp** with an audio-capable model (a dedicated ASR model such as Qwen3-ASR, or a general multimodal model that accepts audio). Ollama is not supported as it has no audio input.
Frigate can send audio to a GenAI provider for transcription when that provider has the `transcribe` role. This is useful if you already run a GenAI provider, or if you do not have the CPU/GPU headroom for a local whisper model. See [Provider support](/configuration/genai/genai_config#provider-support) for which providers can serve this role. The model must accept audio: either a dedicated ASR model such as Qwen3-ASR, or a general multimodal model that accepts audio.
To use a GenAI provider for audio transcription:
+3 -3
View File
@@ -169,10 +169,10 @@ The [Add Camera Wizard](cameras.md#adding-a-camera-with-the-add-camera-wizard) i
1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />.
2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand.
3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower, or an RTSP stream for higher resolution cameras.
4. In the validation step, enable **Use stream compatibility mode** for http-flv streams when the wizard recommends it.
3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower. For higher resolution cameras, it tries the http-flv stream first and falls back to RTSP when the camera does not support it.
4. The wizard turns on **Use stream compatibility mode** for the http-flv stream it selects. Enable it for any other http-flv stream you add, such as the sub stream.
If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras where http-flv is recommended, the wizard will show a warning in the validation step.
If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras 5MP and lower, where http-flv is recommended, the wizard will show a warning in the validation step.
The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below.
+25 -4
View File
@@ -43,7 +43,19 @@ genai:
The examples on this page all use `my_provider`, but the name is arbitrary and is only used to reference the provider elsewhere in the config (for example, `semantic_search.model`).
Each provider handles one or more **roles**: `chat`, `descriptions`, `embeddings`, and `transcribe`. A provider handles the first three by default; `transcribe` must always be listed explicitly, and is not available on Ollama, which has no audio input. Each role may be assigned to exactly one provider. Define a single provider if you want it to do everything, or split the roles across several providers using the `roles` option.
Each provider handles one or more **roles**: `chat`, `descriptions`, `embeddings`, and `transcribe`. A provider handles the first three by default; `transcribe` must always be listed explicitly. Each role may be assigned to exactly one provider. Define a single provider if you want it to do everything, or split the roles across several providers using the `roles` option. Not every provider supports every role; see [Provider support](#provider-support).
### Provider support
| Provider | Descriptions | Chat | Embeddings | Transcription |
| ----------------------------- | :----------: | :--: | :--------: | :-----------: |
| llama.cpp (`llamacpp`) | ✅ | ✅ | ✅ | ✅ |
| Ollama (`ollama`) | ✅ | ✅ | ✅ | ❌ |
| OpenAI (`openai`) | ✅ | ✅ | ❌ | ✅ |
| Azure OpenAI (`azure_openai`) | ✅ | ✅ | ❌ | ✅ |
| Google Gemini (`gemini`) | ✅ | ✅ | ❌ | ✅ |
A ✅ means Frigate can use the provider for that feature. The configured model must also support it: a vision model for descriptions and chat, a multimodal embedding model for embeddings (see [Embedding models](#embedding-models)), and an audio-capable model for transcription. Some features also need extra provider setup, covered in each provider's section below. OpenAI-compatible servers use the `openai` provider, so they follow the OpenAI row.
If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`.
@@ -73,9 +85,10 @@ You must use a vision-capable model with Frigate. The following models are recom
The `embeddings` role needs a different kind of model. Text queries are matched against the stored image embeddings, so the model must be trained to place images and text into the same vector space. A chat or description model will still return vectors when asked, but those vectors are not trained for retrieval and text searches will return poor matches with no error to indicate why.
| Model | Notes |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `qwen3-vl-embedding` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Must be served by llama.cpp started with `--embeddings` and `--mmproj`. |
| Model | Notes |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `embeddinggemma-2` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Strong semantic search accuracy with efficient inference on a small model. |
| `qwen3-vl-embedding` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Good performance, large model that requires strong hardware for inference. |
#### Transcription models
@@ -152,6 +165,10 @@ genai:
Frigate queries the llama.cpp server for the model's context size at startup and logs it along with the other detected capabilities. If `context_size` is set in `provider_options`, that value is always used instead, even when the server reports its own.
#### Embeddings
To serve the `embeddings` role for [Semantic Search](/configuration/semantic_search#genai-provider), start the llama.cpp server with `--embeddings`, plus `--mmproj` for image support. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for details.
### Ollama
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance.
@@ -197,6 +214,10 @@ genai:
</TabItem>
</ConfigTabs>
#### Embeddings
Ollama can serve the `embeddings` role for [Semantic Search](/configuration/semantic_search#genai-provider). Embedding images requires Ollama 0.40.1 or newer and an embedding model with a vision encoder, such as `embeddinggemma-2:440m`. For a saved provider, the UI hides the role unless Ollama reports its model as an embedding model. Use a separate provider entry for the embedding model rather than adding the role to a vision chat model.
### OpenAI-Compatible
Frigate supports any provider that implements the OpenAI API standard. This includes self-hosted solutions like [vLLM](https://docs.vllm.ai/), [LocalAI](https://localai.io/), and other OpenAI-compatible servers.
+3 -6
View File
@@ -133,13 +133,12 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
### GenAI Provider
Frigate can use a GenAI provider for semantic search embeddings when that provider has the `embeddings` role. Currently, only **llama.cpp** supports multimodal embeddings (both text and images).
Frigate can use a GenAI provider for semantic search embeddings when that provider has the `embeddings` role. See [Provider support](/configuration/genai/genai_config#provider-support) for which providers can serve this role.
To use llama.cpp for semantic search:
To use a GenAI provider for semantic search:
1. Configure a GenAI provider with `embeddings` in its `roles`.
1. Configure a GenAI provider with `embeddings` in its `roles`, using a multimodal embedding model (both text and images). See [Embedding models](/configuration/genai/genai_config#embedding-models) for recommendations, and your provider's section of the [GenAI docs](/configuration/genai/genai_config) for any extra setup it needs.
2. Set the semantic search model to the GenAI config key (e.g. `default`).
3. Start the llama.cpp server with `--embeddings` and `--mmproj` for image support.
<ConfigTabs>
<TabItem value="ui">
@@ -174,8 +173,6 @@ semantic_search:
</TabItem>
</ConfigTabs>
The llama.cpp server must be started with `--embeddings` for the embeddings API, and a multi-modal embeddings model. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for details.
:::note
Switching between Jina models and a GenAI provider requires reindexing. Embeddings from different backends are incompatible.
+1 -1
View File
@@ -189,7 +189,7 @@ Frigate is able to utilize an Nvidia GPU which supports the 12.x series of CUDA
#### Minimum Hardware Support
12.x series of CUDA libraries are used which have minor version compatibility. The minimum driver version on the host system must be `>=545`. Also the GPU must support a Compute Capability of `5.0` or greater. This generally correlates to a Maxwell-era GPU or newer, check the NVIDIA GPU Compute Capability table linked below.
12.x series of CUDA libraries are used which have minor version compatibility. The minimum driver version on the host system must be `>=545`. The oldest supported GPU generation for object detection is Pascal (Compute Capability 6.0 or greater). Older generations such as Maxwell are not supported but can still be used by following a [community workaround](https://github.com/blakeblackshear/frigate/discussions/20088). For other GPUs, check the NVIDIA GPU Compute Capability table linked below.
Make sure your host system has the [nvidia-container-runtime](https://docs.docker.com/config/containers/resource_constraints/#access-an-nvidia-gpu) installed to pass through the GPU to the container and the host system has a compatible driver installed for your GPU.
-8
View File
@@ -53,14 +53,6 @@ go2rtc:
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
### Can I add a privacy mask to hide part of my camera's view?
Frigate does not have privacy masks. [Motion masks and object filter masks](../configuration/masks.md) only affect detection, they don't hide anything in live view, recordings, or snapshots.
Privacy masks are best configured in the camera's firmware settings so the area is blacked out before the video ever leaves the camera and no extra processing is needed. Check there first.
If your camera does not support privacy masks, there is no efficient alternative. Frigate copies the camera's video into recordings and live view without decoding it, so part of the image can't be hidden without transcoding (re-encoding) the stream. This can be done with a custom ffmpeg filter in go2rtc, but it is not recommended. Every masked camera needs a continuous re-encode, which significantly increases CPU usage, especially for high resolution streams.
### My mjpeg stream or snapshots look green and crazy
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
+24 -15
View File
@@ -7030,9 +7030,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -7698,14 +7698,15 @@
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"destroy": "1.2.0",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
@@ -7713,6 +7714,10 @@
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/compression/node_modules/bytes": {
@@ -17448,9 +17453,9 @@
"license": "ISC"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -17458,6 +17463,10 @@
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr/node_modules/ipaddr.js": {
@@ -19229,9 +19238,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -19513,9 +19522,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
+31 -5
View File
@@ -1088,6 +1088,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Body
responses:
'200':
@@ -1131,6 +1132,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Body
responses:
'200':
@@ -1285,6 +1287,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Body
responses:
'200':
@@ -1644,6 +1647,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Body
responses:
'200':
@@ -1691,6 +1695,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Body
responses:
'200':
@@ -1738,6 +1743,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Body
responses:
'200':
@@ -1779,6 +1785,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Body
responses:
'200':
@@ -1862,6 +1869,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Body
responses:
'200':
@@ -3483,6 +3491,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Body
responses:
'200':
@@ -5652,6 +5661,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Response Create Trigger Embedding Trigger Embedding Post
'422':
description: Validation Error
@@ -5700,6 +5710,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Response Update Trigger Embedding Trigger Embedding
Camera Name Name Put
'422':
@@ -5742,6 +5753,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Response Delete Trigger Embedding Trigger Embedding
Camera Name Name Delete
'422':
@@ -5778,6 +5790,7 @@ paths:
application/json:
schema:
type: object
additionalProperties: true
title: Response Get Triggers Status Triggers Status Camera Name
Get
'422':
@@ -7574,6 +7587,7 @@ paths:
type: array
items:
type: object
additionalProperties: true
title: Response No Recordings Recordings Unavailable Get
'422':
description: Validation Error
@@ -7777,7 +7791,8 @@ components:
title: Update Topic
config_data:
anyOf:
- type: object
- additionalProperties: true
type: object
- type: 'null'
title: Config Data
skip_save:
@@ -8017,7 +8032,7 @@ components:
properties:
file:
type: string
format: binary
contentMediaType: application/octet-stream
title: File
type: object
required:
@@ -8027,7 +8042,7 @@ components:
properties:
file:
type: string
format: binary
contentMediaType: application/octet-stream
title: File
type: object
required:
@@ -8125,6 +8140,7 @@ components:
tool_calls:
anyOf:
- items:
additionalProperties: true
type: object
type: array
- type: 'null'
@@ -8420,6 +8436,7 @@ components:
- type: 'null'
title: Model Type
data:
additionalProperties: true
type: object
title: Data
type: object
@@ -8491,7 +8508,8 @@ components:
default: true
draw:
anyOf:
- type: object
- additionalProperties: true
type: object
- type: 'null'
title: Draw
default: {}
@@ -8734,7 +8752,8 @@ components:
description: Error message for failed jobs
results:
anyOf:
- type: object
- additionalProperties: true
type: object
- type: 'null'
title: Results
description: Result metadata for completed jobs
@@ -8984,6 +9003,7 @@ components:
- type: 'null'
title: Base Url
provider_options:
additionalProperties: true
type: object
title: Provider Options
type: object
@@ -9524,6 +9544,7 @@ components:
type: string
title: Tool Name
arguments:
additionalProperties: true
type: object
title: Arguments
type: object
@@ -9601,6 +9622,11 @@ components:
type:
type: string
title: Error Type
input:
title: Input
ctx:
type: object
title: Context
type: object
required:
- loc
+1 -1
View File
@@ -702,7 +702,7 @@ def motion_activity(
df = df[df["camera"] != ""]
# change types for output
df.index = df.index.astype(int) // (10**9)
df.index = df.index.as_unit("s").astype(int)
normalized = df.reset_index().to_dict("records")
return JSONResponse(content=normalized)
+3 -3
View File
@@ -12,8 +12,8 @@ from pathlib import Path
import psutil
import uvicorn
from peewee import SqliteDatabase
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.api.auth import hash_password
from frigate.api.fastapi_app import create_fastapi_app
@@ -186,7 +186,7 @@ class FrigateApp:
self.timeline_queue: Queue = mp.Queue()
def init_database(self) -> None:
def vacuum_db(db: SqliteExtDatabase) -> None:
def vacuum_db(db: SqliteDatabase) -> None:
logger.info("Running database vacuum")
db.execute_sql("VACUUM;")
@@ -197,7 +197,7 @@ class FrigateApp:
logger.error("Unable to write to /config to save DB state")
# Migrate DB schema
migrate_db = SqliteExtDatabase(self.config.database.path)
migrate_db = SqliteDatabase(self.config.database.path)
# Run migrations
del logging.getLogger("peewee_migrate").handlers[:]
+6 -6
View File
@@ -54,12 +54,12 @@ class PTZMetrics:
reset: Event
def __init__(self) -> None:
self.start_time = mp.Value("d", 0) # type: ignore[assignment]
self.stop_time = mp.Value("d", 0) # type: ignore[assignment]
self.frame_time = mp.Value("d", 0) # type: ignore[assignment]
self.zoom_level = mp.Value("d", 0) # type: ignore[assignment]
self.max_zoom = mp.Value("d", 0) # type: ignore[assignment]
self.min_zoom = mp.Value("d", 0) # type: ignore[assignment]
self.start_time = mp.Value("d", 0)
self.stop_time = mp.Value("d", 0)
self.frame_time = mp.Value("d", 0)
self.zoom_level = mp.Value("d", 0)
self.max_zoom = mp.Value("d", 0)
self.min_zoom = mp.Value("d", 0)
self.motor_stopped = mp.Event()
self.reset = mp.Event()
+29 -6
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import logging
import queue
import selectors
import socket
import threading
import time
from collections.abc import Callable
@@ -57,6 +58,11 @@ class MqttClient(Communicator):
self._next_connect_time = 0.0
self._last_on_connect_dispatch = 0.0
# lets other threads interrupt the worker's socket wait
self._wake_recv, self._wake_send = socket.socketpair()
self._wake_recv.setblocking(False)
self._wake_send.setblocking(False)
def subscribe(self, receiver: Callable) -> None:
"""Wrapper for allowing dispatcher to subscribe."""
self._dispatcher = receiver
@@ -86,6 +92,7 @@ class MqttClient(Communicator):
return
self._publish_queue.put(QueuedPublish(full_topic, payload, retain))
self._wake_worker()
def stop(self) -> None:
if self._worker is None:
@@ -101,9 +108,11 @@ class MqttClient(Communicator):
publish_done,
)
)
self._wake_worker()
publish_done.wait(MQTT_SHUTDOWN_FLUSH_TIMEOUT)
self._stop_event.set()
self._wake_worker()
if self.client is not None:
try:
@@ -369,11 +378,17 @@ class MqttClient(Communicator):
exc_info=True,
)
def _wake_worker(self) -> None:
try:
self._wake_send.send(b"\0")
except BlockingIOError:
# the buffer is full, so a wake is already pending
pass
def _loop_client(self, timeout: float) -> int:
"""Drive Paho without select()'s limit on socket file descriptors."""
assert self.client is not None
client = self.client
if client is None:
return mqtt.MQTT_ERR_NO_CONN
sock = client.socket()
if sock is None:
return mqtt.MQTT_ERR_NO_CONN
@@ -385,11 +400,19 @@ class MqttClient(Communicator):
pending = hasattr(sock, "pending") and sock.pending() > 0
with selectors.DefaultSelector() as selector:
selector.register(sock, events)
ready = selector.select(0.0 if pending else timeout)
selector.register(self._wake_recv, selectors.EVENT_READ)
ready = {
key.fileobj: mask
for key, mask in selector.select(0.0 if pending else timeout)
}
ready_events = 0
for _, mask in ready:
ready_events |= mask
if self._wake_recv in ready:
try:
self._wake_recv.recv(4096)
except BlockingIOError:
pass
ready_events = ready.get(sock, 0)
if pending or ready_events & selectors.EVENT_READ:
result = client.loop_read()
if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None:
+2 -2
View File
@@ -6,7 +6,7 @@ import logging
import os
import queue
import threading
from collections.abc import Callable
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from multiprocessing.synchronize import Event as MpEvent
from typing import Any
@@ -72,7 +72,7 @@ class WebPushClient(Communicator):
# Pull keys from PEM or generate if they do not exist
self.vapid = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem"))
users: list[dict[str, Any]] = (
users: Iterator[dict[str, Any]] = (
User.select(User.username, User.notification_tokens).dicts().iterator()
)
for user in users:
@@ -580,8 +580,8 @@ class LicensePlateProcessingMixin:
boxes = []
scores = []
for index in range(len(contours)): # type: ignore[arg-type]
contour = contours[index] # type: ignore[index]
for index in range(len(contours)):
contour = contours[index]
# get minimum bounding box (rotated rectangle) around the contour and the smallest side length.
points, sside = self._get_min_boxes(contour)
@@ -1222,7 +1222,7 @@ class LicensePlateProcessingMixin:
"""Look for license plates in image."""
self.metrics.alpr_pps.value = self.plates_rec_second.eps()
self.metrics.yolov9_lpr_pps.value = self.plates_det_second.eps()
camera = obj_data if dedicated_lpr else obj_data["camera"]
camera: str = obj_data if dedicated_lpr else obj_data["camera"] # type: ignore[assignment]
current_time = int(datetime.datetime.now().timestamp())
debug_frame_id = int(datetime.datetime.now().timestamp() * 1000)
+1 -1
View File
@@ -365,7 +365,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
img = cv2.imread(current_file)
if img is None:
return { # type: ignore[unreachable]
return {
"message": "Invalid image file.",
"success": False,
}
+13 -13
View File
@@ -2,10 +2,10 @@
import logging
import os
import shutil
import threading
import warnings
from transformers import AutoFeatureExtractor, AutoTokenizer
from transformers import AutoTokenizer, CLIPImageProcessor
from transformers.utils.logging import disable_progress_bar
from frigate.comms.inter_process import InterProcessRequestor
@@ -21,12 +21,6 @@ from frigate.util.downloader import ModelDownloader
from .base_embedding import BaseEmbedding
warnings.filterwarnings(
"ignore",
category=FutureWarning,
message="The class CLIPFeatureExtractor is deprecated",
)
# disables the progress bar for downloading tokenizers and feature extractors
disable_progress_bar()
logger = logging.getLogger(__name__)
@@ -58,6 +52,13 @@ class JinaV1TextEmbedding(BaseEmbedding):
self._lock = threading.Lock()
files_names = list(self.download_urls.keys()) + [self.tokenizer_file]
# an interrupted download leaves the hub cache without the saved tokenizer
tokenizer_path = os.path.join(self.download_path, self.tokenizer_file)
if os.path.isdir(tokenizer_path) and not os.path.exists(
os.path.join(tokenizer_path, "tokenizer_config.json")
):
shutil.rmtree(tokenizer_path)
if not all(
os.path.exists(os.path.join(self.download_path, n)) for n in files_names
):
@@ -92,7 +93,7 @@ class JinaV1TextEmbedding(BaseEmbedding):
tokenizer = AutoTokenizer.from_pretrained(
self.model_name,
trust_remote_code=True,
trust_remote_code=False,
cache_dir=f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer",
clean_up_tokenization_spaces=True,
)
@@ -123,9 +124,8 @@ class JinaV1TextEmbedding(BaseEmbedding):
f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer"
)
self.tokenizer = AutoTokenizer.from_pretrained(
self.model_name,
cache_dir=tokenizer_path,
trust_remote_code=True,
tokenizer_path,
trust_remote_code=False,
clean_up_tokenization_spaces=True,
)
@@ -209,7 +209,7 @@ class JinaV1ImageEmbedding(BaseEmbedding):
if self.downloader:
self.downloader.wait_for_download()
self.feature_extractor = AutoFeatureExtractor.from_pretrained(
self.feature_extractor = CLIPImageProcessor.from_pretrained(
f"{MODEL_CACHE_DIR}/{self.model_name}",
)
+12 -4
View File
@@ -3,6 +3,7 @@
import io
import logging
import os
import shutil
import threading
import numpy as np
@@ -60,6 +61,14 @@ class JinaV2Embedding(BaseEmbedding):
# download the model and tokenizer
files_names = list(self.download_urls.keys()) + [self.tokenizer_file]
# an interrupted download leaves the hub cache without the saved tokenizer
tokenizer_path = os.path.join(self.download_path, self.tokenizer_file)
if os.path.isdir(tokenizer_path) and not os.path.exists(
os.path.join(tokenizer_path, "tokenizer_config.json")
):
shutil.rmtree(tokenizer_path)
if not all(
os.path.exists(os.path.join(self.download_path, n)) for n in files_names
):
@@ -97,7 +106,7 @@ class JinaV2Embedding(BaseEmbedding):
tokenizer = AutoTokenizer.from_pretrained(
self.model_name,
trust_remote_code=True,
trust_remote_code=False,
cache_dir=os.path.join(
MODEL_CACHE_DIR, self.model_name, "tokenizer"
),
@@ -129,9 +138,8 @@ class JinaV2Embedding(BaseEmbedding):
f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer"
)
self.tokenizer = AutoTokenizer.from_pretrained(
self.model_name,
cache_dir=tokenizer_path,
trust_remote_code=True,
tokenizer_path,
trust_remote_code=False,
clean_up_tokenization_spaces=True,
)
+7 -9
View File
@@ -82,7 +82,7 @@ class EventCleanup(threading.Thread):
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
# grab all events after specific time
expired_events: list[Event] = (
expired_events: list[Event] = list(
Event.select(
Event.id,
Event.camera,
@@ -97,7 +97,6 @@ class EventCleanup(threading.Thread):
.namedtuples()
.iterator()
)
expired_events = list(expired_events)
logger.debug(f"{len(expired_events)} events can be expired")
# delete the media from disk
@@ -159,7 +158,7 @@ class EventCleanup(threading.Thread):
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
# grab all events after specific time
expired_events = (
camera_events = (
Event.select(
Event.id,
Event.camera,
@@ -178,7 +177,7 @@ class EventCleanup(threading.Thread):
# delete the grabbed clips from disk
# only snapshots are stored in /clips
# so no need to delete mp4 files
for event in expired_events:
for event in camera_events:
events_to_update.append(str(event.id))
deleted = delete_event_snapshot(event)
@@ -212,7 +211,7 @@ class EventCleanup(threading.Thread):
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
# grab all events after specific time
expired_events: list[Event] = (
expired_events: list[Event] = list(
Event.select(
Event.id,
Event.camera,
@@ -225,7 +224,6 @@ class EventCleanup(threading.Thread):
.namedtuples()
.iterator()
)
expired_events = list(expired_events)
logger.debug(f"{len(expired_events)} events can be expired")
# delete the media from disk
for expired in expired_events:
@@ -235,7 +233,7 @@ class EventCleanup(threading.Thread):
try:
media_path.unlink(missing_ok=True)
if file_extension == "jpg":
media_path = Path(
media_path = Path( # type: ignore[unreachable]
f"{os.path.join(CLIPS_DIR, media_name)}-clean.webp"
)
media_path.unlink(missing_ok=True)
@@ -289,7 +287,7 @@ class EventCleanup(threading.Thread):
now - datetime.timedelta(days=camera.record.effective_detection_days)
).timestamp()
# grab all events after specific time
expired_events = (
camera_events = (
Event.select(
Event.id,
Event.camera,
@@ -316,7 +314,7 @@ class EventCleanup(threading.Thread):
# delete the grabbed clips from disk
# only snapshots are stored in /clips
# so no need to delete mp4 files
for event in expired_events:
for event in camera_events:
events_to_update.append(event.id)
# update the clips attribute for the db entry
+1 -1
View File
@@ -94,7 +94,7 @@ class EventProcessor(threading.Thread):
if update == None:
continue
source_type, event_type, camera, _, event_data = update # type: ignore[misc]
source_type, event_type, camera, _, event_data = update
logger.debug(
f"Event received: {source_type} {event_type} {camera} {event_data['id']}"
+60 -4
View File
@@ -22,6 +22,7 @@ from frigate.genai.prompts import (
build_review_description_response_format,
build_review_summary_prompt,
)
from frigate.genai.utils import synthetic_jpeg
from frigate.models import Event
from frigate.util.builtin import has_non_finite_number
@@ -37,7 +38,7 @@ __all__ = [
"register_genai_provider",
]
PROVIDERS = {}
PROVIDERS: dict[GenAIProviderEnum, type["GenAIClient"]] = {}
def register_genai_provider(key: GenAIProviderEnum) -> Callable:
@@ -66,6 +67,8 @@ class GenAIClient:
self.genai_config: GenAIConfig = genai_config
self.timeout = timeout
self.validate_model = validate_model
self._image_token_cache: dict[tuple[int, int], int] = {}
self._text_baseline_tokens: int | None = None
self.provider = self._init_provider()
self._last_init_attempt = time.monotonic()
@@ -372,10 +375,63 @@ class GenAIClient:
def estimate_image_tokens(self, width: int, height: int) -> float:
"""Estimate prompt tokens consumed by a single image of the given dimensions.
Default heuristic: ~1 token per 1250 pixels. Providers that can measure or
know their model's exact image-token cost should override.
Providers that implement ``_count_prompt_tokens`` are probed for the
model's real cost: the same minimal prompt is counted with and without a
synthetic image, and the difference is cached per (width, height) since
image tokenization depends only on the dimensions and the loaded model.
Otherwise, or if probing fails, falls back to ~1 token per 1250 pixels.
"""
return (width * height) / 1250
heuristic = (width * height) / 1250
if self.provider is None:
return heuristic
cached = self._image_token_cache.get((width, height))
if cached is not None:
return cached
try:
if self._text_baseline_tokens is None:
self._text_baseline_tokens = self._count_prompt_tokens(None)
if self._text_baseline_tokens is None:
return heuristic
with_image = self._count_prompt_tokens(synthetic_jpeg(width, height))
except Exception as e:
logger.debug(
"%s image-token probe failed for %dx%d (%s); using heuristic",
self.__class__.__name__,
width,
height,
e,
)
return heuristic
if with_image is None:
return heuristic
tokens = max(1, with_image - self._text_baseline_tokens)
self._image_token_cache[(width, height)] = tokens
logger.debug(
"%s model '%s' uses ~%d tokens for %dx%d images",
self.__class__.__name__,
self.genai_config.model,
tokens,
width,
height,
)
return tokens
def _count_prompt_tokens(self, image: bytes | None) -> int | None:
"""Prompt tokens the provider reports for a minimal "." request, with
``image`` attached when given.
Return None when the provider cannot report prompt tokens; raise on
request failures. Used by estimate_image_tokens.
"""
return None
def embed(
self,
+18 -89
View File
@@ -1,7 +1,6 @@
"""llama.cpp Provider for Frigate AI."""
import base64
import io
import json
import logging
from collections.abc import AsyncGenerator
@@ -10,11 +9,14 @@ from typing import Any, cast
import httpx
import numpy as np
import requests
from PIL import Image
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
from frigate.genai.utils import interleave_images, parse_tool_calls_from_message
from frigate.genai.utils import (
interleave_images,
parse_tool_calls_from_message,
to_jpeg,
)
logger = logging.getLogger(__name__)
@@ -76,20 +78,6 @@ def _parse_launch_arg(args: list[str], flag: str) -> str | None:
return args[idx + 1]
def _to_jpeg(img_bytes: bytes) -> bytes | None:
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
try:
img = Image.open(io.BytesIO(img_bytes))
if img.mode != "RGB":
img = img.convert("RGB") # type: ignore[assignment]
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
return buf.getvalue()
except Exception as e:
logger.warning("Failed to convert image to JPEG: %s", e)
return None
@register_genai_provider(GenAIProviderEnum.llamacpp)
class LlamaCppClient(GenAIClient):
"""Generative AI client for Frigate using llama.cpp server."""
@@ -101,8 +89,6 @@ class LlamaCppClient(GenAIClient):
_supports_audio: bool
_supports_tools: bool
_supports_reasoning: bool
_image_token_cache: dict[tuple[int, int], int]
_text_baseline_tokens: int | None
@property
def supports_embeddings(self) -> bool:
@@ -156,8 +142,6 @@ class LlamaCppClient(GenAIClient):
self._supports_audio = False
self._supports_tools = False
self._supports_reasoning = False
self._image_token_cache = {}
self._text_baseline_tokens = None
base_url = (
self.genai_config.base_url.rstrip("/")
@@ -601,78 +585,23 @@ class LlamaCppClient(GenAIClient):
return self._context_size
return 4096
def estimate_image_tokens(self, width: int, height: int) -> float:
"""Probe the llama.cpp server to learn the model's image-token cost at the
requested dimensions.
llama.cpp's image tokenization is a deterministic function of dimensions and
the loaded mmproj, so the result is cached per (width, height) for the
lifetime of the process. Falls back to the base pixel heuristic if the
server is unreachable or the response is malformed.
"""
if self.provider is None:
return super().estimate_image_tokens(width, height)
cached = self._image_token_cache.get((width, height))
if cached is not None:
return cached
try:
baseline = self._probe_baseline_tokens()
with_image = self._probe_image_prompt_tokens(width, height)
tokens = max(1, with_image - baseline)
except Exception as e:
logger.debug(
"llama.cpp image-token probe failed for %dx%d (%s); using heuristic",
width,
height,
e,
)
return super().estimate_image_tokens(width, height)
self._image_token_cache[(width, height)] = tokens
logger.debug(
"llama.cpp model '%s' uses ~%d tokens for %dx%d images",
self.genai_config.model,
tokens,
width,
height,
)
return tokens
def _probe_baseline_tokens(self) -> int:
"""Return prompt_tokens for a minimal text-only request. Cached after first call."""
if self._text_baseline_tokens is not None:
return self._text_baseline_tokens
self._text_baseline_tokens = self._probe_prompt_tokens(
[{"type": "text", "text": "."}]
)
return self._text_baseline_tokens
def _probe_image_prompt_tokens(self, width: int, height: int) -> int:
"""Return prompt_tokens for a single synthetic image plus minimal text."""
img = Image.new("RGB", (width, height), (128, 128, 128))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=60)
encoded = base64.b64encode(buf.getvalue()).decode("utf-8")
return self._probe_prompt_tokens(
[
{"type": "text", "text": "."},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
},
]
)
def _probe_prompt_tokens(self, content: list[dict[str, Any]]) -> int:
def _count_prompt_tokens(self, image: bytes | None) -> int | None:
"""POST a 1-token chat completion and return reported prompt_tokens.
Uses a generous timeout to absorb a cold model load on the first probe
when the server lazily loads models on demand (e.g. llama-swap).
"""
content: list[dict[str, Any]] = [{"type": "text", "text": "."}]
if image is not None:
encoded = base64.b64encode(image).decode("utf-8")
content.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
}
)
payload = {
"model": self.genai_config.model,
"messages": [{"role": "user", "content": content}],
@@ -807,7 +736,7 @@ class LlamaCppClient(GenAIClient):
for img in images:
# llama.cpp uses STB which does not support WebP; convert to JPEG
jpeg_bytes = _to_jpeg(img)
jpeg_bytes = to_jpeg(img)
to_encode = jpeg_bytes if jpeg_bytes is not None else img
encoded = base64.b64encode(to_encode).decode("utf-8")
# The trailing newline keeps tokenization identical to the older
+117 -9
View File
@@ -7,6 +7,7 @@ import logging
from collections.abc import AsyncGenerator
from typing import Any
import numpy as np
from httpx import RemoteProtocolError, TimeoutException
from ollama import AsyncClient as OllamaAsyncClient
from ollama import Client as ApiClient
@@ -14,7 +15,11 @@ from ollama import ResponseError
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
from frigate.genai.utils import interleave_images, parse_tool_calls_from_message
from frigate.genai.utils import (
interleave_images,
parse_tool_calls_from_message,
to_jpeg,
)
logger = logging.getLogger(__name__)
@@ -124,23 +129,39 @@ class OllamaClient(GenAIClient):
provider: ApiClient | None
provider_options: dict[str, Any]
_capabilities_cache: list[str] | None = None
_supports_thinking_cache: bool | None = None
def _model_capabilities(self) -> list[str] | None:
"""Capabilities Ollama reports for the configured model, or None when
they could not be fetched. Only successful lookups are cached."""
if self._capabilities_cache is not None:
return self._capabilities_cache
if self.provider is None:
return None
try:
response = self.provider.show(self.genai_config.model)
except Exception as e:
logger.debug("Failed to query Ollama model capabilities: %s", e)
return None
self._capabilities_cache = list(response.get("capabilities") or [])
return self._capabilities_cache
@property
def supports_toggleable_thinking(self) -> bool:
if self._supports_thinking_cache is not None:
return self._supports_thinking_cache
if self.provider is None:
capabilities = self._model_capabilities()
if capabilities is None:
return False
try:
response = self.provider.show(self.genai_config.model)
capabilities = response.get("capabilities") or []
self._supports_thinking_cache = "thinking" in capabilities
except Exception as e:
logger.debug("Failed to query Ollama model capabilities: %s", e)
self._supports_thinking_cache = False
self._supports_thinking_cache = "thinking" in capabilities
return self._supports_thinking_cache
@property
def supports_embeddings(self) -> bool:
"""Whether Ollama reports the configured model as an embedding model."""
return "embedding" in (self._model_capabilities() or [])
def _auth_headers(self) -> dict | None:
if self.genai_config.api_key:
return {"Authorization": "Bearer " + self.genai_config.api_key}
@@ -322,6 +343,93 @@ class OllamaClient(GenAIClient):
self.genai_config.provider_options.get("options", {}).get("num_ctx", 4096)
)
def _count_prompt_tokens(self, image: bytes | None) -> int | None:
"""Send a 1-token chat request and return Ollama's prompt_eval_count.
Reuses the description request options so the probe runs with the same
num_ctx; a different value would make Ollama reload the model.
"""
if self.provider is None:
return None
message: dict[str, Any] = {"role": "user", "content": "."}
if image is not None:
message["images"] = [image]
request_params = self._build_request_params(
[message], None, None, enable_thinking=False
)
request_params["options"] = {
**(request_params.get("options") or {}),
"num_predict": 1,
}
response = self.provider.chat(**request_params)
count = response.get("prompt_eval_count")
return int(count) if count is not None else None
def embed(
self,
texts: list[str] | None = None,
images: list[bytes] | None = None,
) -> list[np.ndarray]:
"""Generate embeddings via Ollama's /api/embed endpoint.
Each text is a plain string in `input` and each image is an
``{"image": <base64>}`` item. Image input requires Ollama 0.40.1 or
newer and a model with a vision encoder (e.g. embeddinggemma-2:440m).
"""
if self.provider is None:
logger.warning(
"Ollama provider has not been initialized. Check your Ollama configuration."
)
return []
texts = texts or []
images = images or []
if not texts and not images:
return []
inputs: list[str | dict[str, str]] = list(texts)
for img in images:
jpeg_bytes = to_jpeg(img)
to_encode = jpeg_bytes if jpeg_bytes is not None else img
inputs.append({"image": base64.b64encode(to_encode).decode("utf-8")})
payload: dict[str, Any] = {"model": self.genai_config.model, "input": inputs}
for key in ("options", "keep_alive"):
if key in self.genai_config.provider_options:
payload[key] = self.genai_config.provider_options[key]
try:
# The ollama SDK's embed() validates input as strings only, so
# image items have to bypass it and post the JSON directly.
response = self.provider._request_raw("POST", "/api/embed", json=payload)
body = response.json()
except (
TimeoutException,
ResponseError,
RemoteProtocolError,
ConnectionError,
ValueError,
) as e:
logger.warning("Ollama embeddings error: %s", str(e))
return []
vectors = body.get("embeddings") if isinstance(body, dict) else None
if not isinstance(vectors, list):
logger.warning("Ollama embeddings returned unexpected format")
return []
if len(vectors) != len(inputs):
logger.warning(
"Ollama returned %d embeddings for %d inputs",
len(vectors),
len(inputs),
)
return [np.asarray(v, dtype=np.float32).flatten() for v in vectors]
def _build_request_params(
self,
messages: list[dict[str, Any]],
+30
View File
@@ -1,12 +1,42 @@
"""Shared helpers for GenAI providers and chat (OpenAI-style messages, tool call parsing)."""
import io
import json
import logging
from typing import Any
from PIL import Image
logger = logging.getLogger(__name__)
def to_jpeg(img_bytes: bytes) -> bytes | None:
"""Convert image bytes to JPEG.
Some provider image decoders (e.g. llama.cpp's STB) do not support WebP,
which is the format Frigate stores thumbnails in.
"""
try:
img = Image.open(io.BytesIO(img_bytes))
if img.mode != "RGB":
img = img.convert("RGB") # type: ignore[assignment]
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
return buf.getvalue()
except Exception as e:
logger.warning("Failed to convert image to JPEG: %s", e)
return None
def synthetic_jpeg(width: int, height: int) -> bytes:
"""A flat gray JPEG of the given dimensions, for measuring image token cost."""
buf = io.BytesIO()
Image.new("RGB", (width, height), (128, 128, 128)).save(
buf, format="JPEG", quality=60
)
return buf.getvalue()
def interleave_images(
prompt: str, images: list[bytes], captions: list[str] | None = None
) -> list[str | bytes]:
+1 -5
View File
@@ -15,7 +15,7 @@ from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.config.camera.record import ChaptersEnum
from frigate.const import EXPORT_DIR, UPDATE_JOB_STATE
from frigate.const import UPDATE_JOB_STATE
from frigate.jobs.job import Job
from frigate.models import Export
from frigate.record.export import (
@@ -415,10 +415,6 @@ def reap_stale_exports() -> None:
this in a try/except. A failure on a single row will not stop the rest
of the sweep, and a failure in the top-level query will log and return.
"""
# staged stream runs live on disk, so a killed export leaves them behind
for staged in Path(EXPORT_DIR).glob("export_stage_*"):
staged.unlink(missing_ok=True)
try:
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
except Exception:
+1
View File
@@ -85,6 +85,7 @@ def apply_log_levels(default: str, log_levels: dict[str, LogLevel]) -> None:
log_levels = {
"absl": LogLevel.error,
"httpx": LogLevel.error,
"httpx2": LogLevel.error,
"h5py": LogLevel.error,
"keras": LogLevel.error,
"matplotlib": LogLevel.error,
+9
View File
@@ -73,6 +73,15 @@ _KINDS = (
"detect_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
),
NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"),
# every camera process raises it when the shared queue backs up, so repeats
# wait for the next flush instead of writing once per camera
NoticeKind(
"object_processing_behind",
NoticeSeverity.warning,
"system",
link="/system#cameras",
batch_repeats=True,
),
# one row per user per burst; the login log lines carry the address
NoticeKind(
"failed_login",
+2 -2
View File
@@ -360,7 +360,7 @@ class ObjectDetectProcess:
# detection_start is set only after detection_queue.get()
# returns. If it was reset during the grace period, the process
# recovered and may be waiting on the shared queue again.
if self.detection_start.value == 0.0: # type: ignore[attr-defined]
if self.detection_start.value == 0.0:
logging.info("Detection process recovered before restart")
return
@@ -369,7 +369,7 @@ class ObjectDetectProcess:
self.detect_process.join()
logging.info("Detection process has exited...")
self.detection_start.value = 0.0 # type: ignore[attr-defined]
self.detection_start.value = 0.0
# Async path for MemryX
if self.detector_config.type == "memryx":
+8 -2
View File
@@ -30,6 +30,9 @@ from frigate.util.image import (
logger = logging.getLogger(__name__)
# Minimum seconds to hold the current camera before switching to a different one
CAMERA_HOLD_SECONDS = 5
@dataclass(frozen=True, slots=True)
class BirdseyeActivity:
@@ -496,8 +499,11 @@ class BirdsEyeFrameManager:
if max_cameras:
now = datetime.datetime.now().timestamp()
if len(active_cameras) == max_cameras and now - self.last_refresh_time < 10:
# don't refresh cameras too often
if (
len(self.active_cameras) == max_cameras
and len(active_cameras) >= max_cameras
and now - self.last_refresh_time < CAMERA_HOLD_SECONDS
):
active_cameras = self.active_cameras
else:
limited_active_cameras = sorted(
+2 -2
View File
@@ -10,7 +10,7 @@ from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path
from typing import Any
from playhouse.sqlite_ext import SqliteExtDatabase
from peewee import SqliteDatabase
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
from frigate.const import (
@@ -88,7 +88,7 @@ class RecordingCleanup(threading.Thread):
if (
os.stat(f"{self.config.database.path}-wal").st_size / (1024 * 1024)
) > MAX_WAL_SIZE:
db = SqliteExtDatabase(self.config.database.path)
db = SqliteDatabase(self.config.database.path)
db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);")
db.close()
+2 -2
View File
@@ -16,7 +16,7 @@ from enum import Enum
from pathlib import Path
from typing import Any, cast
import pytz # type: ignore[import-untyped]
import pytz
from pathvalidate import sanitize_filename
from peewee import DoesNotExist
@@ -559,7 +559,7 @@ class RecordingExporter(threading.Thread):
)
def _staged_run_path(self, index: int) -> str:
return os.path.join(EXPORT_DIR, f"export_stage_{self.export_id}_{index}.mp4")
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4")
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
"""Probe one recording from a run for its resolution.
+1 -1
View File
@@ -859,7 +859,7 @@ class RecordingMaintainer(threading.Thread):
for box in motion_boxes:
if len(box) < 4:
continue
continue # type: ignore[unreachable]
x1, y1, x2, y2 = box
# Convert pixel coordinates to grid cells
+2 -2
View File
@@ -6,8 +6,8 @@ from unittest.mock import patch
from fastapi import Request
from fastapi.testclient import TestClient
from peewee import SqliteDatabase
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from pydantic import Json
@@ -38,7 +38,7 @@ class AuthTestClient(TestClient):
class BaseTestHttp(unittest.TestCase):
def setUp(self, models):
# setup clean database for each test run
migrate_db = SqliteExtDatabase("test.db")
migrate_db = SqliteDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db)
router.run()
-15
View File
@@ -453,21 +453,6 @@ class TestHttpExport(BaseTestHttp):
assert unchanged.name == "front door export"
assert unchanged.video_path == video
def test_reap_stale_exports_removes_staged_runs(self):
with tempfile.TemporaryDirectory() as tmpdir:
staged = os.path.join(tmpdir, "export_stage_front_door_abc_0.mp4")
finished = os.path.join(tmpdir, "front_door_export.mp4")
for path in (staged, finished):
with open(path, "w") as handle:
handle.write("video")
with patch("frigate.jobs.export.EXPORT_DIR", tmpdir):
reap_stale_exports()
assert not os.path.exists(staged)
assert os.path.exists(finished)
def test_reap_stale_exports_deletes_rows_with_no_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
stale_video = os.path.join(tmpdir, "stale.mp4")
+74
View File
@@ -500,6 +500,80 @@ class TestBirdseyeLiveActivity(unittest.TestCase):
assert self.manager.active_cameras == {"front"}
class TestBirdseyeCameraHold(unittest.TestCase):
"""Test that CAMERA_HOLD_SECONDS prevents rapid camera switching."""
def setUp(self):
config = {
"mqtt": {"enabled": False},
"birdseye": {
"enabled": True,
"modes": ["motion"],
"inactivity_threshold": 30,
},
"cameras": {
camera: {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
}
for camera in ("back", "front")
},
}
self.config = FrigateConfig(**config)
self.manager = BirdsEyeFrameManager(self.config, mp.Event())
for camera_data in self.manager.cameras.values():
camera_data["current_frame"] = None
camera_data["current_frame_time"] = 100.0
camera_data["last_active_frame"] = 0.0
camera_data["live_active"] = False
def test_max_cameras_cooldown_applies_when_more_active_than_max(self):
"""The max_cameras cooldown should apply even when more cameras are active than max."""
self.config.birdseye.layout.max_cameras = 1
self.manager.cameras["front"]["last_active_frame"] = 95.0
self.manager.cameras["front"]["current_frame_time"] = 100.0
self.manager.update_frame()
assert "front" in self.manager.active_cameras
self.manager.cameras["back"]["last_active_frame"] = 99.0
self.manager.cameras["back"]["current_frame_time"] = 100.0
self.manager.update_frame()
assert "front" in self.manager.active_cameras
def test_camera_count_change_ignores_hold(self):
"""Adding a camera (count change) should not be blocked by the hold period."""
self.manager.cameras["front"]["last_active_frame"] = 95.0
self.manager.update_frame()
assert self.manager.active_cameras == {"front"}
self.manager.cameras["back"]["last_active_frame"] = 99.0
self.manager.cameras["back"]["current_frame_time"] = 100.0
self.manager.cameras["front"]["last_active_frame"] = 95.0
self.manager.update_frame()
assert self.manager.active_cameras == {"front", "back"}
def test_max_cameras_count_increase_not_blocked_by_hold(self):
"""With max_cameras=2 showing 1, a second active camera must appear immediately."""
self.config.birdseye.layout.max_cameras = 2
self.manager.cameras["front"]["last_active_frame"] = 95.0
self.manager.cameras["front"]["current_frame_time"] = 100.0
self.manager.update_frame()
assert self.manager.active_cameras == {"front"}
self.manager.cameras["back"]["last_active_frame"] = 99.0
self.manager.cameras["back"]["current_frame_time"] = 100.0
self.manager.update_frame()
assert self.manager.active_cameras == {"front", "back"}
class TestBirdseyeModePayload(unittest.TestCase):
"""Test the MQTT payload contract for Birdseye activity modes."""
@@ -7,7 +7,7 @@ import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock
from playhouse.sqlite_ext import SqliteExtDatabase
from peewee import SqliteDatabase
from frigate.api.chat import (
_execute_find_similar_objects,
@@ -98,7 +98,7 @@ class TestExecuteFindSimilarObjects(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.tmp.close()
self.db = SqliteExtDatabase(self.tmp.name)
self.db = SqliteDatabase(self.tmp.name)
Event.bind(self.db, bind_refs=False, bind_backrefs=False)
self.db.connect()
self.db.create_tables([Event])
+2 -2
View File
@@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, patch
import cv2
import numpy as np
from playhouse.sqlite_ext import SqliteExtDatabase
from peewee import SqliteDatabase
from frigate.api.chat import (
TOOL_REJECTED_RESULT,
@@ -214,7 +214,7 @@ class DatabaseTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.tmp.close()
self.db = SqliteExtDatabase(self.tmp.name)
self.db = SqliteDatabase(self.tmp.name)
for model in self.models:
model.bind(self.db, bind_refs=False, bind_backrefs=False)
self.db.connect()
@@ -3,7 +3,7 @@
import unittest
from unittest.mock import MagicMock, patch
from playhouse.sqlite_ext import SqliteExtDatabase
from peewee import SqliteDatabase
from frigate.comms.dispatcher import Dispatcher
from frigate.const import INSERT_MANY_RECORDINGS
@@ -30,7 +30,7 @@ class TestInsertManyRecordings(unittest.TestCase):
"""A duplicate path must not cost the rest of the batch."""
def setUp(self):
self.db = SqliteExtDatabase(":memory:")
self.db = SqliteDatabase(":memory:")
self.db.bind([Recordings])
self.db.create_tables([Recordings])
+103
View File
@@ -0,0 +1,103 @@
"""Tests for the notice raised when object processing drops camera frames."""
import threading
import unittest
from unittest.mock import patch
from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.notices.types import NOTICE_KINDS
from frigate.video.detect import (
DROPPED_FRAMES_NOTICE_COUNT,
DROPPED_FRAMES_NOTICE_INTERVAL_S,
DROPPED_FRAMES_WINDOW_S,
DroppedFrameTracker,
)
class TestDroppedFrameTracker(unittest.TestCase):
def setUp(self):
raise_patch = patch("frigate.video.detect.raise_notice")
self.raise_notice = raise_patch.start()
self.addCleanup(raise_patch.stop)
def _drop(self, tracker: DroppedFrameTracker, count: int, start: float) -> None:
for i in range(count):
tracker.dropped(start + i * 0.2)
# the notice is sent on a background thread
if tracker._sender is not None:
tracker._sender.join(timeout=5)
def test_kind_is_registered(self):
self.assertIn("object_processing_behind", NOTICE_KINDS)
def test_a_few_drops_raise_nothing(self):
tracker = DroppedFrameTracker("front_door")
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT - 1, 0.0)
self.raise_notice.assert_not_called()
def test_enough_drops_raise_the_notice(self):
tracker = DroppedFrameTracker("front_door")
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT, 0.0)
self.raise_notice.assert_called_once_with("object_processing_behind")
def test_drops_outside_the_window_do_not_add_up(self):
tracker = DroppedFrameTracker("front_door")
for i in range(DROPPED_FRAMES_NOTICE_COUNT):
tracker.dropped(i * (DROPPED_FRAMES_WINDOW_S + 1.0))
self.raise_notice.assert_not_called()
def test_repeats_wait_for_the_interval(self):
tracker = DroppedFrameTracker("front_door")
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT, 0.0)
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT * 2, 5.0)
self.assertEqual(self.raise_notice.call_count, 1)
self._drop(
tracker, DROPPED_FRAMES_NOTICE_COUNT, DROPPED_FRAMES_NOTICE_INTERVAL_S
)
self.assertEqual(self.raise_notice.call_count, 2)
def test_a_pending_send_does_not_block_or_start_another(self):
sending = threading.Event()
release = threading.Event()
def wait_for_reply(*_) -> None:
sending.set()
release.wait(5)
self.raise_notice.side_effect = wait_for_reply
tracker = DroppedFrameTracker("front_door")
# the first notice gets no reply while the second burst arrives
for i in range(DROPPED_FRAMES_NOTICE_COUNT):
tracker.dropped(i * 0.2)
self.assertTrue(sending.wait(5))
for i in range(DROPPED_FRAMES_NOTICE_COUNT):
tracker.dropped(DROPPED_FRAMES_NOTICE_INTERVAL_S + i * 0.2)
self.assertEqual(self.raise_notice.call_count, 1)
release.set()
assert tracker._sender is not None
tracker._sender.join(timeout=5)
def test_replay_camera_never_raises(self):
tracker = DroppedFrameTracker(f"{REPLAY_CAMERA_PREFIX}front_door")
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT * 2, 0.0)
self.raise_notice.assert_not_called()
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -4,7 +4,7 @@ import datetime
import unittest
from unittest.mock import MagicMock
from playhouse.sqlite_ext import SqliteExtDatabase
from peewee import SqliteDatabase
from frigate.config import FrigateConfig
from frigate.events.cleanup import EventCleanup
@@ -15,7 +15,7 @@ class TestEventCleanupSubRetention(unittest.TestCase):
def setUp(self):
# in-memory database keeps these tests isolated from the shared
# on-disk test.db used by the http api tests
self.db = SqliteExtDatabase(":memory:")
self.db = SqliteDatabase(":memory:")
models = [Event, Timeline]
self.db.bind(models)
self.db.create_tables(models)
+190
View File
@@ -427,6 +427,148 @@ class TestOllamaProvider(unittest.TestCase):
self.assertEqual(message["content"], "prompt\n[img]")
self.assertEqual(message["images"], [b"a"])
def test_capabilities_drive_embeddings_and_thinking(self):
client = self._client()
client.provider = MagicMock()
client.provider.show.return_value = {"capabilities": ["embedding", "vision"]}
self.assertTrue(client.supports_embeddings)
self.assertFalse(client.supports_toggleable_thinking)
client.provider.show.assert_called_once()
def test_capability_lookup_failure_is_not_cached(self):
from ollama import ResponseError
client = self._client()
client.provider = MagicMock()
client.provider.show.side_effect = [
ResponseError("unavailable", 503),
{"capabilities": ["embedding"]},
]
self.assertFalse(client.supports_embeddings)
self.assertTrue(client.supports_embeddings)
def test_thinking_rechecked_after_provider_recovers(self):
client = self._client()
client.provider = None
self.assertFalse(client.supports_toggleable_thinking)
client.provider = MagicMock()
client.provider.show.return_value = {"capabilities": ["thinking"]}
self.assertTrue(client.supports_toggleable_thinking)
params = client._build_request_params(
[{"role": "user", "content": "hi"}], None, None, enable_thinking=True
)
self.assertTrue(params["think"])
@staticmethod
def _webp_bytes():
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (8, 8), (200, 10, 10)).save(buf, format="WEBP")
return buf.getvalue()
def test_embed_posts_text_and_image_items(self):
client = self._client()
client.provider = MagicMock()
client.provider._request_raw.return_value.json.return_value = {
"embeddings": [[0.1] * 768, [0.2] * 768]
}
result = client.embed(texts=["a person"], images=[self._webp_bytes()])
args = client.provider._request_raw.call_args
self.assertEqual(args.args, ("POST", "/api/embed"))
payload = args.kwargs["json"]
self.assertEqual(payload["model"], "llama3")
self.assertEqual(payload["input"][0], "a person")
self.assertEqual(list(payload["input"][1]), ["image"])
# WebP thumbnails are converted to JPEG before being sent
image = base64.b64decode(payload["input"][1]["image"])
self.assertEqual(image[:2], b"\xff\xd8")
self.assertEqual(len(result), 2)
self.assertAlmostEqual(float(result[1][0]), 0.2, places=5)
def test_embed_passes_configured_options(self):
client = _make_client(
"ollama",
model="embeddinggemma-2",
base_url="http://localhost:9999",
provider_options={"options": {"num_ctx": 2048}, "keep_alive": "10m"},
)
client.provider = MagicMock()
client.provider._request_raw.return_value.json.return_value = {
"embeddings": [[0.1] * 768]
}
client.embed(texts=["a"])
payload = client.provider._request_raw.call_args.kwargs["json"]
self.assertEqual(payload["options"], {"num_ctx": 2048})
self.assertEqual(payload["keep_alive"], "10m")
@staticmethod
def _chat_counting_prompt_tokens(**params):
"""Fake chat that reports 10 prompt tokens plus 250 per image."""
images = params["messages"][0].get("images") or []
return {
"message": {"content": "."},
"prompt_eval_count": 10 + 250 * len(images),
}
def test_image_tokens_probed_with_one_token_requests(self):
client = _make_client(
"ollama",
model="qwen3-vl",
base_url="http://localhost:9999",
provider_options={"options": {"num_ctx": 16384}},
)
client.provider = MagicMock()
client.provider.chat.side_effect = self._chat_counting_prompt_tokens
client._supports_thinking_cache = False
self.assertEqual(client.estimate_image_tokens(320, 180), 250)
calls = client.provider.chat.call_args_list
self.assertEqual(len(calls), 2)
for call in calls:
self.assertEqual(
call.kwargs["options"], {"num_ctx": 16384, "num_predict": 1}
)
image = calls[1].kwargs["messages"][0]["images"][0]
self.assertEqual(image[:2], b"\xff\xd8")
def test_image_token_probe_error_uses_heuristic(self):
from ollama import ResponseError
client = self._client()
client.provider = MagicMock()
client.provider.chat.side_effect = [
{"message": {"content": "."}, "prompt_eval_count": 10},
ResponseError("model does not support images", 400),
]
client._supports_thinking_cache = False
self.assertEqual(client.estimate_image_tokens(250, 100), 20)
self.assertEqual(client._image_token_cache, {})
def test_embed_server_error_returns_empty(self):
from ollama import ResponseError
client = self._client()
client.provider = MagicMock()
client.provider._request_raw.side_effect = ResponseError(
"model does not support media embeddings", 400
)
self.assertEqual(client.embed(images=[self._webp_bytes()]), [])
# ---------------------------------------------------------------------------
# llama.cpp
@@ -638,6 +780,46 @@ class TestLlamaCppProvider(unittest.TestCase):
self.assertEqual([r.shape for r in result], [(768,), (768,)])
self.assertEqual(float(result[1][-1]), 0.0)
@staticmethod
def _post_counting_prompt_tokens(url, json=None, timeout=None):
"""Fake chat completion: 12 prompt tokens plus 300 per image part."""
content = json["messages"][0]["content"]
images = [p for p in content if p["type"] == "image_url"]
response = MagicMock()
response.json.return_value = {
"usage": {"prompt_tokens": 12 + 300 * len(images)}
}
return response
def test_image_tokens_probed_once_per_dimension(self):
client = self._client()
with patch.object(
client, "_post", side_effect=self._post_counting_prompt_tokens
) as post:
self.assertEqual(client.estimate_image_tokens(320, 180), 300)
self.assertEqual(client.estimate_image_tokens(320, 180), 300)
self.assertEqual(client.estimate_image_tokens(640, 360), 300)
# one shared text baseline, then one image request per new dimension
self.assertEqual(post.call_count, 3)
payload = post.call_args_list[0].kwargs["json"]
self.assertEqual(payload["max_tokens"], 1)
self.assertEqual(
post.call_args_list[0].args[0], "http://localhost:9999/v1/chat/completions"
)
def test_image_token_probe_failure_is_not_cached(self):
client = self._client()
with patch.object(
client, "_post", side_effect=requests.exceptions.ConnectionError("down")
):
self.assertEqual(client.estimate_image_tokens(250, 100), 20)
self.assertEqual(client._image_token_cache, {})
self.assertIsNone(client._text_baseline_tokens)
def test_embed_request_error_returns_empty(self):
client = self._client()
response = MagicMock()
@@ -903,6 +1085,14 @@ class TestLlamaCppTranscribe(unittest.TestCase):
post.assert_not_called()
class TestImageTokenEstimate(unittest.TestCase):
def test_provider_without_token_counts_uses_heuristic(self):
client = _make_client("gemini", model="m", api_key="k")
self.assertEqual(client.estimate_image_tokens(250, 100), 20)
self.assertEqual(client._image_token_cache, {})
class TestBaseClientTranscribe(unittest.TestCase):
"""Providers that don't implement the role must be inert, not broken."""
+2 -2
View File
@@ -9,8 +9,8 @@ import logging
import os
import unittest
from peewee import SqliteDatabase
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.api.media_auth import (
@@ -208,7 +208,7 @@ class TestExportResolution(unittest.TestCase):
"""Export resolution requires a DB lookup."""
def setUp(self):
migrate_db = SqliteExtDatabase("test.db")
migrate_db = SqliteDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:]
Router(migrate_db).run()
migrate_db.close()
+16 -3
View File
@@ -126,12 +126,18 @@ class TestMqttClientLifecycle(unittest.TestCase):
os.makedirs(MODEL_CACHE_DIR)
self.config = build_config()
self.client = MqttClient(self.config)
self.client = self._build_client()
self.receiver = RuntimeSnapshotReceiver()
self.client.attach_dispatcher(build_dispatcher(self.config, []))
def test_subscribe_stores_receiver_without_starting_worker(self) -> None:
def _build_client(self) -> MqttClient:
client = MqttClient(self.config)
self.addCleanup(client._wake_recv.close)
self.addCleanup(client._wake_send.close)
return client
def test_subscribe_stores_receiver_without_starting_worker(self) -> None:
client = self._build_client()
with patch.object(client, "_start_worker") as mock_start_worker:
client.subscribe(self.receiver._receive)
@@ -142,7 +148,7 @@ class TestMqttClientLifecycle(unittest.TestCase):
mock_start_worker.assert_not_called()
def test_attach_dispatcher_supplies_command_surface(self) -> None:
client = MqttClient(self.config)
client = self._build_client()
self.assertFalse(client._is_supported_command_topic("front/detect/set"))
@@ -295,6 +301,13 @@ class TestMqttClientLifecycle(unittest.TestCase):
self.assertEqual(self.client._subscription_mid, 42)
self.client.client.subscribe.assert_called_once_with("frigate/#", qos=0)
def test_publish_wakes_worker(self) -> None:
self.client.connected = True
self.client.publish("events", "payload")
self.assertEqual(self.client._wake_recv.recv(16), b"\0")
def test_handle_connect_event_reconnects_on_recoverable_subscribe_error(
self,
) -> None:
+19 -3
View File
@@ -2,6 +2,7 @@ import fcntl
import resource
import selectors
import socket
import time
import unittest
from unittest.mock import MagicMock, patch
@@ -23,6 +24,11 @@ class TestMqttNetworkLoop(unittest.TestCase):
self.sock, self.peer = socket.socketpair()
self.addCleanup(self.sock.close)
self.addCleanup(self.peer.close)
self.transport._wake_recv, self.transport._wake_send = socket.socketpair()
self.transport._wake_recv.setblocking(False)
self.transport._wake_send.setblocking(False)
self.addCleanup(self.transport._wake_recv.close)
self.addCleanup(self.transport._wake_send.close)
self.client.socket.return_value = self.sock
def test_high_fd_handles_connack_suback_publish_and_puback(self) -> None:
@@ -123,8 +129,18 @@ class TestMqttNetworkLoop(unittest.TestCase):
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_CONN_LOST)
self.client.loop_misc.assert_not_called()
def test_missing_client_or_socket_reports_no_connection(self) -> None:
def test_missing_socket_reports_no_connection(self) -> None:
self.client.socket.return_value = None
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
self.transport.client = None
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
def test_wake_interrupts_wait_and_is_consumed(self) -> None:
self.transport._wake_worker()
start = time.monotonic()
self.assertEqual(self.transport._loop_client(5), mqtt.MQTT_ERR_SUCCESS)
self.assertLess(time.monotonic() - start, 1)
self.client.loop_read.assert_not_called()
start = time.monotonic()
self.transport._loop_client(0.2)
self.assertGreater(time.monotonic() - start, 0.15)
+2 -2
View File
@@ -5,8 +5,8 @@ import os
import unittest
from unittest.mock import MagicMock, patch
from peewee import SqliteDatabase
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.models import Notice, NoticeStats
@@ -49,7 +49,7 @@ class TestNoticeKinds(unittest.TestCase):
class RegistryTestCase(unittest.TestCase):
def setUp(self):
migrate_db = SqliteExtDatabase("test.db")
migrate_db = SqliteDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db)
router.run()
+2 -2
View File
@@ -4,7 +4,7 @@ import datetime
import unittest
from unittest.mock import MagicMock
from playhouse.sqlite_ext import SqliteExtDatabase
from peewee import SqliteDatabase
from frigate.config import FrigateConfig
from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
@@ -15,7 +15,7 @@ class TestRecordingCleanupSubRetention(unittest.TestCase):
def setUp(self):
# in-memory database keeps these tests isolated from the shared
# on-disk test.db used by the http api tests
self.db = SqliteExtDatabase(":memory:")
self.db = SqliteDatabase(":memory:")
models = [Previews, Recordings, ReviewSegment, UserReviewStatus]
self.db.bind(models)
self.db.create_tables(models)
+2 -2
View File
@@ -8,7 +8,7 @@ import unittest
from collections import defaultdict
from unittest.mock import AsyncMock, MagicMock, patch
from playhouse.sqlite_ext import SqliteExtDatabase
from peewee import SqliteDatabase
from frigate.config import FrigateConfig
from frigate.models import Recordings
@@ -719,7 +719,7 @@ class TestSegmentChainSeeding(unittest.IsolatedAsyncioTestCase):
T0 = datetime.datetime(2026, 6, 10, 14, 30, 22, tzinfo=datetime.UTC).timestamp()
def setUp(self):
self.db = SqliteExtDatabase(":memory:")
self.db = SqliteDatabase(":memory:")
self.db.bind([Recordings])
self.db.create_tables([Recordings])
+2 -19
View File
@@ -5,7 +5,7 @@ import json
import unittest
from types import SimpleNamespace
from playhouse.sqlite_ext import SqliteExtDatabase
from peewee import SqliteDatabase
from frigate.api.media import _vod_response
from frigate.const import MAX_SEGMENT_DURATION
@@ -13,7 +13,6 @@ from frigate.models import Recordings
from frigate.util.recording_coverage import (
_rows_query,
coverage_spans,
null_audio_glitches,
plan_clip,
realized_timeline,
resolve_coverage,
@@ -25,7 +24,7 @@ class CoverageDbTestCase(unittest.TestCase):
def setUp(self):
# in-memory database keeps these tests isolated from the shared
# on-disk test.db used by the http api tests
self.db = SqliteExtDatabase(":memory:")
self.db = SqliteDatabase(":memory:")
models = [Recordings]
self.db.bind(models)
self.db.create_tables(models)
@@ -167,22 +166,6 @@ class TestRecordingCoverage(CoverageDbTestCase):
expected = int(5 * 1024 * 1024 * 8 / 10)
assert summary["main"]["bitrate"] == expected
def test_unknown_audio_row_keeps_video_only_stream(self):
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=False)
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=None)
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=False)
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
self.assertEqual(
[i.sub.path for i in kept], [f"/tmp/s{n}.mp4" for n in (1, 2, 3)]
)
def test_video_only_glitch_dropped_on_audio_stream(self):
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=True)
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=False)
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=None)
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
self.assertEqual([i.sub.path for i in kept], ["/tmp/s1.mp4", "/tmp/s3.mp4"])
def test_other_camera_rows_excluded(self):
self._insert("m1", 1000.0, 1010.0, "main")
self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard")
+2 -3
View File
@@ -5,9 +5,8 @@ import tempfile
import unittest
from unittest.mock import MagicMock, patch
from peewee import DoesNotExist
from peewee import DoesNotExist, SqliteDatabase
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.config import FrigateConfig
@@ -20,7 +19,7 @@ from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
class TestHttp(unittest.TestCase):
def setUp(self):
# setup clean database for each test run
migrate_db = SqliteExtDatabase("test.db")
migrate_db = SqliteDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db)
router.run()
+4 -20
View File
@@ -747,14 +747,8 @@ def collect_object_classification_examples(
selected_events = _select_balanced_events(events, target_count=100)
logger.debug(f"Selected {len(selected_events)} events")
# Step 3: Extract thumbnails from events, falling back to the remaining
# events when the selected ones have no image on disk
selected_ids = {e.id for e in selected_events}
remaining_events = [e for e in events if e.id not in selected_ids]
random.shuffle(remaining_events)
thumbnails = _extract_event_thumbnails(
selected_events + remaining_events, temp_dir, target_count=100
)
# Step 3: Extract thumbnails from events
thumbnails = _extract_event_thumbnails(selected_events, temp_dir)
logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails")
# Step 4: Select 24 most visually distinct thumbnails
@@ -839,16 +833,10 @@ def _select_balanced_events(
else:
selected.extend(remaining)
# groups are ordered oldest first, so truncating unshuffled keeps only the
# oldest events, which are the least likely to still have images on disk
random.shuffle(selected)
return selected[:target_count]
def _extract_event_thumbnails(
events: list[Event], output_dir: str, target_count: int = 100
) -> list[str]:
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]:
"""
Extract a training image for each event.
@@ -862,9 +850,8 @@ def _extract_event_thumbnails(
using a step ladder sized from the box/region area ratio.
Args:
events: List of Event objects, in order of preference
events: List of Event objects
output_dir: Directory to save crops
target_count: Number of images to extract before stopping
Returns:
List of paths to successfully extracted images
@@ -872,9 +859,6 @@ def _extract_event_thumbnails(
image_paths = []
for idx, event in enumerate(events):
if len(image_paths) >= target_count:
break
try:
img = _load_event_classification_crop(event)
if img is None:
+3 -4
View File
@@ -204,12 +204,11 @@ def coverage_spans(intervals: list[CoverageInterval]) -> list[dict[str, Any]]:
def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
"""Whether a stream is audio-bearing over a coverage window.
A stream counts as audio-bearing only when one of its rows is known
to carry audio. A NULL row (legacy, or a segment ffprobe could not
read) proves nothing either way.
A stream counts as audio-bearing unless EVERY one of its rows reports
has_audio False; NULL (legacy or undetermined) counts as audio.
"""
return any(
row is not None and row.has_audio is True
row is not None and row.has_audio is not False
for row in ((interval.main if main else interval.sub) for interval in intervals)
)
+61
View File
@@ -2,7 +2,9 @@
import logging
import queue
import threading
import time
from collections import deque
from datetime import UTC, datetime
from multiprocessing import Queue
from multiprocessing.synchronize import Event as MpEvent
@@ -20,10 +22,12 @@ from frigate.config.camera.updater import (
)
from frigate.const import (
PROCESS_PRIORITY_HIGH,
REPLAY_CAMERA_PREFIX,
REQUEST_REGION_GRID,
)
from frigate.motion import MotionDetector
from frigate.motion.improved_motion import ImprovedMotionDetector
from frigate.notices import raise_notice
from frigate.object_detection.base import RemoteObjectDetector
from frigate.ptz.autotrack import ptz_moving_at_frame_time
from frigate.track import ObjectTracker
@@ -52,6 +56,61 @@ from frigate.util.time import get_tomorrow_at_time
logger = logging.getLogger(__name__)
# this many frames dropped within the window because the shared detected frames
# queue was full means tracked object processing is falling behind
DROPPED_FRAMES_NOTICE_COUNT = 5
DROPPED_FRAMES_WINDOW_S = 30
DROPPED_FRAMES_NOTICE_INTERVAL_S = 60
class DroppedFrameTracker:
"""Raises a notice when a camera drops frames because object processing is behind."""
def __init__(self, camera: str) -> None:
# a debug replay can feed frames faster than real time
self._enabled = not camera.startswith(REPLAY_CAMERA_PREFIX)
self._drops: deque[float] = deque()
self._last_notice: float | None = None
# raising a notice waits on the main process, which answers every
# process's requests one at a time, so it runs off the frame loop
self._sender: threading.Thread | None = None
def dropped(self, now: float) -> None:
"""Record a dropped frame and raise the notice if enough were dropped.
Args:
now: Monotonic time of the drop in seconds
"""
if not self._enabled:
return
self._drops.append(now)
while now - self._drops[0] > DROPPED_FRAMES_WINDOW_S:
self._drops.popleft()
if (
len(self._drops) < DROPPED_FRAMES_NOTICE_COUNT
or (
self._last_notice is not None
and now - self._last_notice < DROPPED_FRAMES_NOTICE_INTERVAL_S
)
or (self._sender is not None and self._sender.is_alive())
):
return
self._drops.clear()
self._last_notice = now
self._sender = threading.Thread(
target=raise_notice,
args=("object_processing_behind",),
name="dropped_frames_notice",
daemon=True,
)
self._sender.start()
class CameraTracker(FrigateProcess):
def __init__(
@@ -207,6 +266,7 @@ def process_frames(
fps_tracker = EventsPerSecond()
fps_tracker.start()
dropped_frames = DroppedFrameTracker(camera_config.name)
startup_scan = True
stationary_frame_counter = 0
@@ -542,6 +602,7 @@ def process_frames(
# add to the queue if not full
if detected_objects_queue.full():
frame_manager.close(frame_name)
dropped_frames.dropped(time.monotonic())
continue
else:
fps_tracker.update()
+1 -1
View File
@@ -116,7 +116,7 @@ class FrigateWatchdog(threading.Thread):
now = datetime.datetime.now().timestamp()
for name, detector in self.detectors.items():
detection_start = detector.detection_start.value # type: ignore[attr-defined]
detection_start = detector.detection_start.value
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
if detection_start > 0.0 and now - detection_start > 10:
+32 -25
View File
@@ -136,33 +136,36 @@ ACCESS_NOTES = {
}
# Mirrors the router set wired up in frigate.api.fastapi_app.
ROUTERS = [
auth.router,
camera.router,
chat.router,
classification.router,
review.router,
main_app.router,
preview.router,
notification.router,
export.router,
hardware.router,
notices.router,
event.router,
media.router,
motion_search.router,
record.router,
debug_replay.router,
]
def build_app() -> FastAPI:
"""Build a bare app with every router mounted.
This mirrors the router set wired up in frigate.api.fastapi_app. It omits
the global admin dependency and all runtime state; the OpenAPI route table
and the per-route dependencies are all we need to export and classify.
It omits the global admin dependency and all runtime state; the OpenAPI
route table and the per-route dependencies are all we need to export and
classify.
"""
app = FastAPI()
routers = [
auth.router,
camera.router,
chat.router,
classification.router,
review.router,
main_app.router,
preview.router,
notification.router,
export.router,
hardware.router,
notices.router,
event.router,
media.router,
motion_search.router,
record.router,
debug_replay.router,
]
for router in routers:
for router in ROUTERS:
app.include_router(router)
return app
@@ -318,13 +321,17 @@ def _classify_base(
def build_access_map(
app: FastAPI,
exempt_paths: set[str],
exempt_prefixes: tuple[str, ...],
) -> dict[tuple[str, str], dict]:
"""Map (path, lowercase method) -> classification details."""
access_map: dict[tuple[str, str], dict] = {}
for route in app.routes:
# app.routes holds opaque wrappers for included routers on newer FastAPI.
# The routers mount without a prefix, so their own routes carry final paths.
routes = [route for router in ROUTERS for route in router.routes]
for route in routes:
if not isinstance(route, APIRoute):
continue
level, roles, flag = classify_route(route, exempt_paths, exempt_prefixes)
@@ -515,7 +522,7 @@ def render(spec: dict) -> str:
def build_spec() -> tuple[dict, dict, list, list, list]:
app = build_app()
exempt_paths, exempt_prefixes = read_exempt_rules()
access_map = build_access_map(app, exempt_paths, exempt_prefixes)
access_map = build_access_map(exempt_paths, exempt_prefixes)
spec = base_document(app.openapi())
normalized = strip_volatile_defaults(spec)
+1
View File
@@ -2,5 +2,6 @@
target-version = "py311"
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]
ignore = ["E501","E711","E712","UP031","UP032","UP042","G004"]
extend-select = ["I", "UP", "G", "ASYNC210", "B904"]
@@ -0,0 +1,155 @@
/**
* Add-camera wizard - Reolink stream selection with the brand template.
*
* The wizard asks the camera for its resolution, then probes http-flv first
* above 5MP and falls back to RTSP. The Step 4 RTSP warning is only for
* cameras that should be on http-flv. An http-flv stream the wizard selects
* is registered with go2rtc through the ffmpeg module.
*/
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
const FLV_PATH = "channel0_main.bcs";
const RTSP_PATH = "Preview_01_main";
const RTSP_WARNING = "Reolink RTSP is not recommended";
const HTTP_WARNING = "Reolink HTTP streams should use FFmpeg";
const FFPROBE_OK = [
{
return_code: 0,
stderr: [],
stdout: {
streams: [
{
codec_type: "video",
codec_name: "hevc",
width: 3840,
height: 2160,
avg_frame_rate: "15/1",
},
{ codec_type: "audio", codec_name: "aac" },
],
},
},
];
const FFPROBE_FAILED = [
{ return_code: 1, stderr: ["probe failed"], stdout: "" },
];
/**
* Mock the camera's answers and drive the wizard to Step 3. Returns the
* dialog and the stream paths the wizard probed, in order.
*/
async function gotoStep3(
page: Page,
{ protocol, flvProbes }: { protocol: string | null; flvProbes: boolean },
) {
const probed: string[] = [];
await page.route("**/api/reolink/detect**", (route) =>
route.fulfill({ json: { success: protocol !== null, protocol } }),
);
await page.route("**/api/ffprobe**", (route) => {
const paths = new URL(route.request().url()).searchParams.get("paths");
const isFlv = !!paths?.includes(FLV_PATH);
probed.push(isFlv ? FLV_PATH : RTSP_PATH);
return route.fulfill({
json: isFlv && !flvProbes ? FFPROBE_FAILED : FFPROBE_OK,
});
});
await page.route("**/api/ffprobe/snapshot**", (route) =>
route.fulfill({ status: 500 }),
);
await page.getByRole("button", { name: /Add New Camera/i }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByPlaceholder(/front_door/i).fill("reolink_test_camera");
await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100");
await dialog.getByPlaceholder("Optional").first().fill("admin");
await dialog.getByPlaceholder("Optional").last().fill("pw");
await dialog.getByText("Manual selection").click();
await dialog.getByRole("combobox").click();
await page.getByRole("option", { name: "Reolink" }).click();
await dialog.getByRole("button", { name: /^Continue$/i }).click();
// Step 2 tests the connection on its own, then offers Continue
const next = dialog.getByRole("button", { name: /^Continue$/i });
await expect(next).toBeEnabled({ timeout: 10_000 });
await next.click();
await expect(
dialog.getByRole("button", { name: /Add Another Stream/i }),
).toBeVisible();
return { dialog, probed };
}
test.describe("Camera wizard Reolink stream selection @medium @mobile", () => {
test.beforeEach(async ({ frigateApp }) => {
// not in the default mock; unmocked it 500s and trips the error collector
await frigateApp.page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: {} }),
);
await frigateApp.goto("/settings?page=cameraManagement");
await expect(
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
).toBeVisible();
});
test("above 5MP keeps http-flv when it probes", async ({ frigateApp }) => {
const { dialog, probed } = await gotoStep3(frigateApp.page, {
protocol: "rtsp",
flvProbes: true,
});
expect(probed).toEqual([FLV_PATH]);
await expect(dialog.locator(`input[value*="${FLV_PATH}"]`)).toBeVisible();
const registered: string[] = [];
await frigateApp.page.route("**/api/go2rtc/streams/**", (route) => {
const src = new URL(route.request().url()).searchParams.get("src");
if (src) registered.push(src);
return route.fulfill({ json: {} });
});
await dialog.getByRole("button", { name: /^Next$/i }).click();
await expect(
dialog.getByRole("button", { name: /Save New Camera/i }),
).toBeVisible();
await expect.poll(() => registered[0]).toMatch(/^ffmpeg:http:\/\//);
await expect(dialog.getByText(HTTP_WARNING)).toHaveCount(0);
});
test("above 5MP falls back to RTSP without a warning", async ({
frigateApp,
}) => {
const { dialog, probed } = await gotoStep3(frigateApp.page, {
protocol: "rtsp",
flvProbes: false,
});
expect(probed).toEqual([FLV_PATH, RTSP_PATH]);
await expect(dialog.locator(`input[value*="${RTSP_PATH}"]`)).toBeVisible();
await dialog.getByRole("button", { name: /^Next$/i }).click();
await expect(
dialog.getByRole("button", { name: /Save New Camera/i }),
).toBeVisible();
await expect(dialog.getByText(RTSP_WARNING)).toHaveCount(0);
});
test("failed detection uses RTSP and warns", async ({ frigateApp }) => {
const { dialog, probed } = await gotoStep3(frigateApp.page, {
protocol: null,
flvProbes: true,
});
expect(probed).toEqual([RTSP_PATH]);
await dialog.getByRole("button", { name: /^Next$/i }).click();
await expect(dialog.getByText(RTSP_WARNING)).toBeVisible();
});
});
+2 -46
View File
@@ -14,17 +14,6 @@ const NOW = Math.floor(Date.now() / 1000);
// the fixture detector runs at 75.5 ms, above the live warning threshold
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } };
// the fixture has no go2rtc streams, which gives every camera a live view hint
const RESTREAMED = {
go2rtc: {
streams: {
front_door: ["rtsp://x"],
backyard: ["rtsp://x"],
garage: ["rtsp://x"],
},
},
};
const ERROR_NOTICE = {
id: "model_download_failed:yolo/model.onnx",
kind: "model_download_failed",
@@ -62,7 +51,6 @@ test.describe("System — Health tab @medium", () => {
frigateApp,
}) => {
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE],
});
@@ -96,7 +84,6 @@ test.describe("System — Health tab @medium", () => {
for (const action of ["acknowledge", "mute"] as const) {
test(`${action} posts and removes the row`, async ({ frigateApp }) => {
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
notices: [EVENT_NOTICE],
});
@@ -139,10 +126,7 @@ test.describe("System — Health tab @medium", () => {
}
test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.goto("/system#health");
await expect(
@@ -345,7 +329,6 @@ test.describe("System — Health tab @medium", () => {
frigateApp,
}) => {
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE],
});
@@ -782,7 +765,6 @@ test.describe("System — Health notices sources @medium", () => {
test("status bar problems stay out of the list", async ({ frigateApp }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({
config: RESTREAMED,
stats: {
service: { retention_unmet: true },
cameras: { front_door: { camera_fps: 0 } },
@@ -847,28 +829,6 @@ test.describe("System — Health notices sources @medium", () => {
).toBeVisible({ timeout: 15_000 });
});
test("a camera without a go2rtc stream gets a live view hint", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { go2rtc: { streams: { front_door: ["rtsp://x"] } } },
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"health-problem-config:live:no-go2rtc-stream:camera.backyard",
);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row).toHaveAttribute("data-severity", "info");
await expect(row).toContainText("lower frame rate and no audio");
await expect(
frigateApp.page.getByTestId(
"health-problem-config:live:no-go2rtc-stream:camera.front_door",
),
).toHaveCount(0);
});
test("a global config problem is not repeated per camera", async ({
frigateApp,
}) => {
@@ -933,10 +893,7 @@ test.describe("System — Health notices sources @medium", () => {
test("empty state when stats, config, and registry are clean", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.goto("/system#health");
await expect(
@@ -1173,7 +1130,6 @@ test.describe("System — Health notices sources @medium", () => {
}) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
notices: [EVENT_NOTICE],
});
+801 -951
View File
File diff suppressed because it is too large Load Diff
+54 -54
View File
@@ -23,33 +23,33 @@
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^5.9.1",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-context-menu": "^2.3.7",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-hover-card": "^1.1.23",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-radio-group": "^1.4.7",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slider": "^1.4.7",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toggle": "^1.1.18",
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@rjsf/core": "^6.10.0",
"@rjsf/shadcn": "^6.10.0",
"@rjsf/utils": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"apexcharts": "^7.3.0",
"@radix-ui/react-alert-dialog": "^1.1.24",
"@radix-ui/react-aspect-ratio": "^1.1.16",
"@radix-ui/react-checkbox": "^1.3.12",
"@radix-ui/react-collapsible": "^1.1.21",
"@radix-ui/react-context-menu": "^2.3.8",
"@radix-ui/react-dialog": "^1.2.0",
"@radix-ui/react-dropdown-menu": "^2.1.25",
"@radix-ui/react-hover-card": "^1.1.24",
"@radix-ui/react-label": "^2.1.16",
"@radix-ui/react-popover": "^1.2.0",
"@radix-ui/react-progress": "^1.1.17",
"@radix-ui/react-radio-group": "^1.4.8",
"@radix-ui/react-scroll-area": "^1.3.0",
"@radix-ui/react-select": "^2.3.8",
"@radix-ui/react-separator": "^1.1.16",
"@radix-ui/react-slider": "^1.5.0",
"@radix-ui/react-slot": "1.4.0",
"@radix-ui/react-switch": "^1.3.8",
"@radix-ui/react-tabs": "^1.1.22",
"@radix-ui/react-toggle": "^1.1.19",
"@radix-ui/react-toggle-group": "^1.1.20",
"@radix-ui/react-tooltip": "^1.3.0",
"@rjsf/core": "^6.10.1",
"@rjsf/shadcn": "^6.10.1",
"@rjsf/utils": "^6.10.1",
"@rjsf/validator-ajv8": "^6.10.1",
"apexcharts": "^7.8.0",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -57,77 +57,77 @@
"copy-to-clipboard": "^4.0.2",
"date-fns": "^4.4.0",
"date-fns-tz": "^3.2.0",
"framer-motion": "^13.3.0",
"framer-motion": "^14.0.0",
"hls.js": "^1.7.3",
"i18next": "^26.4.2",
"i18next-http-backend": "^4.0.2",
"idb-keyval": "^6.3.0",
"js-yaml": "^5.4.2",
"konva": "^10.5.0",
"js-yaml": "^5.4.3",
"konva": "^10.7.1",
"lodash": "^4.18.1",
"lucide-react": "^1.46.0",
"lucide-react": "^1.52.0",
"monaco-yaml": "^5.5.1",
"next-themes": "^0.4.6",
"nosleep.js": "^0.12.0",
"react": "^19.3.0",
"react-apexcharts": "^2.1.1",
"react-day-picker": "^9.14.0",
"react-day-picker": "^10.0.2",
"react-device-detect": "^2.2.3",
"react-dom": "^19.3.0",
"react-dropzone": "^20.1.2",
"react-grid-layout": "^2.2.4",
"react-hook-form": "^7.88.0",
"react-i18next": "^17.0.14",
"react-grid-layout": "^2.3.0",
"react-hook-form": "^7.89.0",
"react-i18next": "^17.0.16",
"react-icons": "^5.7.0",
"react-konva": "^19.2.7",
"react-konva": "^19.3.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.30.6",
"react-router-dom": "^7.18.4",
"react-swipeable": "^7.0.2",
"react-zoom-pan-pinch": "3.4.4",
"react-zoom-pan-pinch": "3.7.0",
"remark-gfm": "^4.0.0",
"scroll-into-view-if-needed": "^3.1.0",
"sonner": "^2.0.8",
"swr": "^2.5.1",
"tailwind-merge": "^2.4.0",
"tailwind-merge": "^2.6.1",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.3.0",
"vaul": "^1.1.2",
"virtua": "^0.51.3",
"virtua": "^0.53.3",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.63.0",
"@playwright/test": "^1.64.0",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.25",
"@types/node": "^26.5.1",
"@types/node": "^26.6.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"autoprefixer": "^10.6.0",
"@vitejs/plugin-react": "^6.1.2",
"autoprefixer": "^10.6.1",
"esbuild": "^0.28.2",
"eslint": "^10.10.0",
"eslint": "^10.12.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.12.0",
"globals": "^17.13.0",
"i18next-cli": "^1.5.11",
"monaco-editor": "^0.52.2",
"monaco-editor": "^0.54.0",
"patch-package": "^8.0.1",
"postcss": "^8.5.12",
"prettier": "^3.9.6",
"postcss": "^8.5.29",
"prettier": "^3.9.9",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^3.4.9",
"typescript": "^5.9.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0"
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"typescript-eslint": "^8.71.1",
"vite": "^8.3.3"
},
"overrides": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-slot": "1.2.4"
"@radix-ui/react-slot": "1.4.0"
}
}
@@ -1,46 +0,0 @@
diff --git a/node_modules/@radix-ui/react-slot/dist/index.js b/node_modules/@radix-ui/react-slot/dist/index.js
index 3691205..3b62ea8 100644
--- a/node_modules/@radix-ui/react-slot/dist/index.js
+++ b/node_modules/@radix-ui/react-slot/dist/index.js
@@ -85,11 +85,12 @@ function createSlotClone(ownerName) {
if (isLazyComponent(children) && typeof use === "function") {
children = use(children._payload);
}
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
+ const composedRef = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, childrenRef);
if (React.isValidElement(children)) {
- const childrenRef = getElementRef(children);
const props2 = mergeProps(slotProps, children.props);
if (children.type !== React.Fragment) {
- props2.ref = forwardedRef ? (0, import_react_compose_refs.composeRefs)(forwardedRef, childrenRef) : childrenRef;
+ props2.ref = forwardedRef ? composedRef : childrenRef;
}
return React.cloneElement(children, props2);
}
diff --git a/node_modules/@radix-ui/react-slot/dist/index.mjs b/node_modules/@radix-ui/react-slot/dist/index.mjs
index d7ea374..a990150 100644
--- a/node_modules/@radix-ui/react-slot/dist/index.mjs
+++ b/node_modules/@radix-ui/react-slot/dist/index.mjs
@@ -1,6 +1,6 @@
// src/slot.tsx
import * as React from "react";
-import { composeRefs } from "@radix-ui/react-compose-refs";
+import { composeRefs, useComposedRefs } from "@radix-ui/react-compose-refs";
import { Fragment as Fragment2, jsx } from "react/jsx-runtime";
var REACT_LAZY_TYPE = Symbol.for("react.lazy");
var use = React[" use ".trim().toString()];
@@ -45,11 +45,12 @@ function createSlotClone(ownerName) {
if (isLazyComponent(children) && typeof use === "function") {
children = use(children._payload);
}
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
+ const composedRef = useComposedRefs(forwardedRef, childrenRef);
if (React.isValidElement(children)) {
- const childrenRef = getElementRef(children);
const props2 = mergeProps(slotProps, children.props);
if (children.type !== React.Fragment) {
- props2.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
+ props2.ref = forwardedRef ? composedRef : childrenRef;
}
return React.cloneElement(children, props2);
}
@@ -1997,9 +1997,6 @@
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
},
"live": {
"noGo2rtcStream": "Live view for this camera is using a basic player with a lower frame rate and no audio. Set up a go2rtc stream for this camera to get smoother video and audio."
},
"audio": {
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
},
+1
View File
@@ -53,6 +53,7 @@
"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",
"object_processing_behind": "Tracked object processing could not keep up, so camera frames were dropped. Check CPU utilization, and whether CPU limits or pinning on the container or VM are leaving Frigate too little CPU",
"failed_login_one": "Failed login attempt for {{user}}",
"failed_login_other": "Failed login attempts for {{user}}",
"update_available": "Frigate {{version}} is available"
+1 -1
View File
@@ -44,7 +44,7 @@ function App() {
return (
<Providers>
<BrowserRouter basename={window.baseUrl}>
<BrowserRouter basename={window.baseUrl} useTransitions={false}>
<Wrapper>
{config?.safe_mode ? <SafeAppView /> : <DefaultAppView />}
</Wrapper>
@@ -1,24 +1,8 @@
import { isRestreamedStream } from "@/utils/liveTranscode";
import type { SectionConfigOverrides } from "./types";
const live: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/live",
messages: [
{
key: "no-go2rtc-stream",
health: true,
messageKey: "configMessages.live.noGo2rtcStream",
severity: "info",
docLink: "/configuration/live",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
return !Object.values(ctx.fullCameraConfig.live.streams).some(
(name) => isRestreamedStream(ctx.fullConfig, name),
);
},
},
],
restartRequired: [],
fieldOrder: ["streams", "transcode", "height", "quality"],
fieldGroups: {},
@@ -15,7 +15,6 @@ import {
hardwareForDevices,
MAX_DETECTORS,
recommendedDetectorCount,
resolveUnitDevice,
} from "@/utils/detectionHardware";
type HardwarePickerProps = {
@@ -61,13 +60,9 @@ export function HardwarePicker({
return [];
}
const assigned = devices.map((device) =>
resolveUnitDevice(selected, device),
);
return selected.units
.map((unit) => unit.device)
.filter((device) => assigned.includes(device));
.filter((device) => devices.includes(device));
}, [selected, devices]);
/** Spread `count` detectors round robin over the selected units. */
@@ -205,7 +200,7 @@ export function HardwarePicker({
<Checkbox
id={`${idPrefix}-${unit.device}`}
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={selectedUnits.includes(unit.device)}
checked={devices.includes(unit.device)}
disabled={disabled || Boolean(claimedBy)}
onCheckedChange={(checked) =>
handleUnitToggle(unit.device, checked === true)
@@ -155,7 +155,7 @@ export function CombinedStorageGraph({
formatter: function (val, opts) {
const entry = opts ? series[opts.seriesIndex] : undefined;
if (entry) {
return `${getUnitSize(entry.usage)} (${val.toFixed(2)}%)`;
return `${getUnitSize(entry.usage)} (${(val ?? 0).toFixed(2)}%)`;
}
},
},
@@ -222,6 +222,7 @@ export default function Step2ProbeOrSnapshot({
wizardData.username,
wizardData.password,
);
update.reolinkProtocol = protocol;
if (protocol === "http-flv") {
update.brandTemplate = "reolink";
}
@@ -294,73 +295,72 @@ export default function Step2ProbeOrSnapshot({
[probeUri],
);
const generateDynamicStreamUrl = useCallback(
async (data: Partial<WizardFormData>): Promise<string | null> => {
const generateDynamicStreamUrls = useCallback(
async (data: Partial<WizardFormData>): Promise<string[]> => {
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
if (!brand || !data.host) return null;
const host = data.host;
if (!brand || !host) return [];
let protocol = undefined;
if (data.brandTemplate === "reolink" && data.username && data.password) {
try {
protocol = await detectReolinkCamera(
data.host,
host,
data.username,
data.password,
);
} catch {
return null;
return [];
}
onUpdate({ reolinkProtocol: protocol });
}
const protocolKey = protocol || "rtsp";
// Only some Reolink cameras above 5MP serve http-flv, so RTSP is the
// fallback when the http-flv stream does not probe.
const protocolKeys =
protocol === "rtsp" ? ["http-flv", "rtsp"] : [protocol || "rtsp"];
const templates: Record<string, string> = brand.dynamicTemplates || {};
if (Object.keys(templates).includes(protocolKey)) {
const template =
templates[protocolKey as keyof typeof brand.dynamicTemplates];
return template
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", data.host);
}
return null;
return protocolKeys
.filter((key) => key in templates)
.map((key) =>
templates[key]
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", host),
);
},
[],
[onUpdate],
);
const generateStreamUrl = useCallback(
async (data: Partial<WizardFormData>): Promise<string> => {
const generateStreamUrls = useCallback(
async (data: Partial<WizardFormData>): Promise<string[]> => {
if (data.brandTemplate === "other") {
return data.customUrl || "";
return data.customUrl ? [data.customUrl] : [];
}
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
if (!brand || !data.host) return "";
if (!brand || !data.host) return [];
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
const dynamicUrl = await generateDynamicStreamUrl(data);
if (dynamicUrl) {
return dynamicUrl;
}
return "";
return generateDynamicStreamUrls(data);
}
return brand.template
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", data.host);
return [
brand.template
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", data.host),
];
},
[generateDynamicStreamUrl],
[generateDynamicStreamUrls],
);
const testConnection = useCallback(
async (showToast = true) => {
const streamUrl = await generateStreamUrl(wizardData);
const streamUrls = await generateStreamUrls(wizardData);
if (!streamUrl) {
if (streamUrls.length === 0) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
@@ -370,8 +370,18 @@ export default function Step2ProbeOrSnapshot({
setTestResult(null);
try {
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
const result = await probeUri(streamUrl, true, setTestStatus);
let streamUrl = streamUrls[0];
let result: TestResult | undefined;
for (const url of streamUrls) {
streamUrl = url;
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
result = await probeUri(url, true, setTestStatus);
if (result.success && result.resolution) {
break;
}
}
if (result && result.success) {
setTestResult(result);
@@ -383,6 +393,9 @@ export default function Step2ProbeOrSnapshot({
url: streamUrl,
roles: ["detect"] as StreamRole[],
testResult: result,
useFfmpeg:
wizardData.brandTemplate === "reolink" &&
streamUrl.startsWith("http://"),
},
],
});
@@ -434,7 +447,7 @@ export default function Step2ProbeOrSnapshot({
setTestStatus("");
}
},
[wizardData, generateStreamUrl, t, onUpdate, probeUri],
[wizardData, generateStreamUrls, t, onUpdate, probeUri],
);
const handleContinue = useCallback(() => {
@@ -499,6 +499,7 @@ function StreamIssues({
url: stream.url,
roles: stream.roles,
brand: wizardData.brandTemplate,
reolinkProtocol: wizardData.reolinkProtocol,
useFfmpeg: stream.useFfmpeg,
restream: stream.restream,
testResult: stream.testResult,
+1
View File
@@ -110,6 +110,7 @@ export type WizardFormData = {
username?: string;
password?: string;
brandTemplate?: CameraBrand;
reolinkProtocol?: "http-flv" | "rtsp" | null; // null when detection failed
customUrl?: string;
streams?: StreamConfig[];
probeMode?: boolean; // true for probe, false for manual
+4 -25
View File
@@ -21,31 +21,10 @@ export function hardwareForDevices(
return undefined;
}
return hardware.find((entry) =>
devices.every((device) => resolveUnitDevice(entry, device)),
);
}
/**
* The unit device string a configured device refers to.
*
* A config may leave the index off, such as "edgetpu:usb", which the detector
* resolves to the first unit of that kind.
*/
export function resolveUnitDevice(
entry: DetectionHardware,
device: string,
): string | undefined {
const unitDevices = entry.units.map((unit) => unit.device);
return (
unitDevices.find((candidate) => candidate === device) ??
unitDevices.find(
(candidate) =>
candidate.startsWith(`${device}:`) ||
candidate.startsWith(`${device}.`),
)
);
return hardware.find((entry) => {
const known = new Set(entry.units.map((unit) => unit.device));
return devices.every((device) => known.has(device));
});
}
/**
+2 -1
View File
@@ -13,6 +13,7 @@ export type StreamIssueInput = {
url: string;
roles: StreamRole[];
brand?: CameraBrand;
reolinkProtocol?: "http-flv" | "rtsp" | null;
useFfmpeg?: boolean;
restream?: boolean;
testResult?: TestResult;
@@ -96,7 +97,7 @@ export function getStreamIssues(
if (input.brand === "reolink") {
const streamUrl = input.url.toLowerCase();
if (streamUrl.startsWith("rtsp://")) {
if (streamUrl.startsWith("rtsp://") && input.reolinkProtocol !== "rtsp") {
result.push({
type: "warning",
rule: "reolink-rtsp",
+1 -1
View File
@@ -5,7 +5,7 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"baseUrl": ".",
"types": ["node"],
"paths": {
"@/*": ["./src/*"]
},