mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-19 21:22:50 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3656a5043 |
@@ -17,14 +17,9 @@ runs:
|
||||
shell: bash
|
||||
# This creates a virtual volume at /var/lib/docker to maximize the size
|
||||
# As of 2/14/2024, this results in 97G for docker images
|
||||
# Runners no longer have a separate /mnt disk, so the temp PV is also carved
|
||||
# from root and temp-reserve-mb is what actually stays free on root. Keep 4G
|
||||
# there for setup-qemu/buildx caches in ~/.docker and the tool cache
|
||||
- name: Maximize build space
|
||||
uses: easimon/maximize-build-space@master
|
||||
with:
|
||||
root-reserve-mb: 8192
|
||||
temp-reserve-mb: 4096
|
||||
remove-dotnet: 'true'
|
||||
remove-android: 'true'
|
||||
remove-haskell: 'true'
|
||||
@@ -34,15 +29,15 @@ runs:
|
||||
run: sudo systemctl start docker
|
||||
shell: bash
|
||||
- id: lowercaseRepo
|
||||
uses: ASzc/change-string-case-action@v8
|
||||
uses: ASzc/change-string-case-action@v5
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+22
-139
@@ -1,157 +1,40 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
# Local composite actions are only scanned when their directory is listed
|
||||
directories:
|
||||
- "/"
|
||||
- "/.github/actions/setup"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
actions:
|
||||
patterns: ["*"]
|
||||
actions-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
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
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/docker/main"
|
||||
schedule:
|
||||
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"]
|
||||
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
target-branch: dev
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/docker/tensorrt"
|
||||
schedule:
|
||||
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"]
|
||||
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
target-branch: dev
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/web"
|
||||
schedule:
|
||||
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"]
|
||||
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
target-branch: dev
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs"
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
interval: daily
|
||||
allow:
|
||||
- dependency-name: "@docusaurus/*"
|
||||
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"]
|
||||
open-pull-requests-limit: 10
|
||||
target-branch: dev
|
||||
|
||||
@@ -586,7 +586,7 @@ jobs:
|
||||
- arm64_build
|
||||
steps:
|
||||
- id: lowercaseRepo
|
||||
uses: ASzc/change-string-case-action@v8
|
||||
uses: ASzc/change-string-case-action@v6
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- id: lowercaseRepo
|
||||
uses: ASzc/change-string-case-action@v8
|
||||
uses: ASzc/change-string-case-action@v6
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
|
||||
@@ -217,8 +217,6 @@ COPY docker/main/rootfs/ /
|
||||
|
||||
# Frigate deps (ffmpeg, python, nginx, go2rtc, s6-overlay, etc)
|
||||
FROM slim-base AS deps
|
||||
LABEL org.opencontainers.image.source="https://github.com/blakeblackshear/frigate"
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG BASE_IMAGE
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ruff == 0.17.0
|
||||
ruff == 0.15.20
|
||||
|
||||
# types
|
||||
types-peewee == 4.0.*
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
numpy
|
||||
openvino >= 2026.4.1
|
||||
openvino >= 2026.2.0
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
aiofiles == 25.1.*
|
||||
click == 8.5.*
|
||||
# FastAPI
|
||||
aiohttp == 3.14.*
|
||||
starlette == 1.7.*
|
||||
aiohttp == 3.12.*
|
||||
starlette == 0.47.*
|
||||
starlette-context == 0.5.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.142.*
|
||||
uvicorn == 0.54.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
|
||||
uvicorn == 0.52.*
|
||||
slowapi == 0.1.*
|
||||
joserfc == 1.7.*
|
||||
cryptography == 50.0.*
|
||||
joserfc == 1.6.*
|
||||
cryptography == 46.0.*
|
||||
pathvalidate == 3.3.*
|
||||
markupsafe == 3.0.*
|
||||
python-multipart == 0.0.32
|
||||
python-multipart == 0.0.31
|
||||
# Classification Model Training
|
||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||
tensorflow-cpu == 2.21.* ; platform_machine == 'x86_64'
|
||||
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
|
||||
# General
|
||||
mypy == 2.4.0
|
||||
onvif-zeep-async == 4.3.*
|
||||
mypy == 1.6.1
|
||||
onvif-zeep-async == 4.0.*
|
||||
paho-mqtt == 2.1.*
|
||||
pandas == 3.0.*
|
||||
peewee == 4.5.*
|
||||
peewee_migrate == 2.3.*
|
||||
psutil == 7.2.*
|
||||
pydantic == 2.14.*
|
||||
pandas == 2.2.*
|
||||
peewee == 3.17.*
|
||||
peewee_migrate == 1.14.*
|
||||
psutil == 7.1.*
|
||||
pydantic == 2.10.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
tzdata == 2026.5
|
||||
pyzmq == 27.2.*
|
||||
pytz == 2025.*
|
||||
pyzmq == 27.1.*
|
||||
ruamel.yaml == 0.19.*
|
||||
tzlocal == 5.4.4
|
||||
requests == 2.34.*
|
||||
types-requests == 2.33.*
|
||||
tzlocal == 5.2
|
||||
requests == 2.33.*
|
||||
types-requests == 2.32.*
|
||||
norfair == 2.3.*
|
||||
setproctitle == 1.3.*
|
||||
ws4py == 0.6.*
|
||||
ws4py == 0.5.*
|
||||
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.17.*
|
||||
scipy == 1.16.*
|
||||
# OpenVino & ONNX
|
||||
openvino == 2026.4.*
|
||||
openvino == 2025.4.*
|
||||
onnxruntime == 1.30.*
|
||||
# Embeddings
|
||||
transformers == 5.19.*
|
||||
transformers == 5.17.*
|
||||
# Generative AI
|
||||
google-genai == 2.31.*
|
||||
google-genai == 1.58.*
|
||||
ollama == 0.6.*
|
||||
openai == 3.26.*
|
||||
openai == 1.65.*
|
||||
# push notifications
|
||||
py-vapid == 1.9.6
|
||||
pywebpush == 2.5.*
|
||||
py-vapid == 1.9.4
|
||||
pywebpush == 2.0.*
|
||||
# alpr
|
||||
pyclipper == 1.4.*
|
||||
shapely == 2.2.*
|
||||
rapidfuzz==3.14.*
|
||||
shapely == 2.0.*
|
||||
rapidfuzz==3.12.*
|
||||
# HailoRT
|
||||
argcomplete==3.7.*
|
||||
contextlib2==21.6.*
|
||||
future==1.0.*
|
||||
contextlib2==0.6.*
|
||||
future==0.18.*
|
||||
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.14.*
|
||||
soundfile==0.13.*
|
||||
# Memory profiling
|
||||
memray == 1.20.*
|
||||
|
||||
@@ -1 +1 @@
|
||||
scikit-build == 0.19.*
|
||||
scikit-build == 0.18.*
|
||||
|
||||
@@ -36,8 +36,8 @@ else
|
||||
lefile="/config/tls/fullchain.pem"
|
||||
fi
|
||||
|
||||
tls_enabled=`python3 -I /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled`
|
||||
listen_external_port=`python3 -I /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
|
||||
tls_enabled=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled`
|
||||
listen_external_port=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
|
||||
|
||||
while true
|
||||
do
|
||||
@@ -61,7 +61,7 @@ do
|
||||
;;
|
||||
esac
|
||||
|
||||
liveprint=`openssl s_client -showcerts -connect 127.0.0.1:$listen_external_port </dev/null 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'`
|
||||
liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:$listen_external_port 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'`
|
||||
|
||||
case "$liveprint" in
|
||||
*Fingerprint*)
|
||||
|
||||
@@ -11,13 +11,30 @@ if [[ "$(id -u)" -eq 0 ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# /root survives s6-setuidgid and breaks cache writes after the drop; set
|
||||
# before opt_in_out so the opt-out marker lands where the service will look
|
||||
if [[ "$runs_as_root" -eq 0 ]]; then
|
||||
export HOME=/config
|
||||
fi
|
||||
|
||||
# detector runtimes installed at first start (pip install --user) live under
|
||||
# $HOME/.local; the dynamic loader only reads LD_LIBRARY_PATH at exec time
|
||||
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${HOME}/.local/lib"
|
||||
export PATH="${PATH}:${HOME}/.local/bin"
|
||||
|
||||
# opt out of openvino telemetry
|
||||
if [ -e /usr/local/bin/opt_in_out ]; then
|
||||
/usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Logs should be sent to stdout so that s6 can collect them
|
||||
|
||||
# Tell S6-Overlay not to restart this service
|
||||
s6-svc -O .
|
||||
|
||||
# Runs as root with root's HOME and LD_LIBRARY_PATH; -I skips user site-packages
|
||||
function set_libva_version() {
|
||||
local ffmpeg_path
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||
export LIBAVFORMAT_VERSION_MAJOR
|
||||
}
|
||||
@@ -25,32 +42,14 @@ function set_libva_version() {
|
||||
echo "[INFO] Preparing Frigate..."
|
||||
set_libva_version
|
||||
|
||||
# HOME=/root survives s6-setuidgid, so any run that is not root gets
|
||||
# HOME=/config explicitly. The runtime user owns /config, so these paths go
|
||||
# only to the service process, never to root. Detector runtimes installed at
|
||||
# first start (pip install --user) live under $HOME/.local.
|
||||
run_as=()
|
||||
runtime_home="${HOME}"
|
||||
if [[ "$runs_as_root" -eq 0 ]]; then
|
||||
runtime_home=/config
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
run_as=(s6-setuidgid frigate)
|
||||
fi
|
||||
fi
|
||||
run_as+=(env
|
||||
"HOME=${runtime_home}"
|
||||
"LD_LIBRARY_PATH=${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${runtime_home}/.local/lib"
|
||||
"PATH=${PATH}:${runtime_home}/.local/bin")
|
||||
|
||||
# opt out of openvino telemetry; the marker lands under the service's HOME
|
||||
if [ -e /usr/local/bin/opt_in_out ]; then
|
||||
"${run_as[@]}" /usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "[INFO] Starting Frigate..."
|
||||
|
||||
cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate"
|
||||
|
||||
# Replace the bash process with the Frigate process, redirecting stderr to stdout
|
||||
exec 2>&1
|
||||
exec "${run_as[@]}" python3 -u -m frigate
|
||||
if [[ "$(id -u)" -ne 0 || "$runs_as_root" -eq 1 ]]; then
|
||||
exec python3 -u -m frigate
|
||||
else
|
||||
exec s6-setuidgid frigate python3 -u -m frigate
|
||||
fi
|
||||
|
||||
@@ -59,33 +59,38 @@ function get_ip_and_port_from_supervisor() {
|
||||
|
||||
function set_libva_version() {
|
||||
local ffmpeg_path
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||
export LIBAVFORMAT_VERSION_MAJOR
|
||||
}
|
||||
|
||||
set_libva_version
|
||||
|
||||
# create_config.py creates the file exclusively and fails if anything
|
||||
# reappears at this path
|
||||
rm -f /dev/shm/go2rtc.yaml
|
||||
|
||||
echo "[INFO] Preparing new go2rtc config..."
|
||||
|
||||
if [[ -n "${SUPERVISOR_TOKEN:-}" ]]; then
|
||||
# Running as a Home Assistant Add-on, infer the IP address and port
|
||||
get_ip_and_port_from_supervisor
|
||||
if [[ -f "/dev/shm/go2rtc.yaml" ]]; then
|
||||
echo "[INFO] Removing stale config from last run..."
|
||||
rm /dev/shm/go2rtc.yaml
|
||||
fi
|
||||
|
||||
python3 -I /usr/local/go2rtc/create_config.py
|
||||
if [[ ! -f "/dev/shm/go2rtc.yaml" ]]; then
|
||||
echo "[INFO] Preparing new go2rtc config..."
|
||||
|
||||
if [[ -n "${SUPERVISOR_TOKEN:-}" ]]; then
|
||||
# Running as a Home Assistant Add-on, infer the IP address and port
|
||||
get_ip_and_port_from_supervisor
|
||||
fi
|
||||
|
||||
python3 /usr/local/go2rtc/create_config.py
|
||||
else
|
||||
echo "[WARNING] Unable to remove existing go2rtc config. Changes made to your frigate config file may not be recognized. Please remove the /dev/shm/go2rtc.yaml from your docker host manually."
|
||||
fi
|
||||
|
||||
# HomeKit persistence. The helper is symlink-safe; hand off to go2rtc only when dropping.
|
||||
readonly homekit_config_path="/config/go2rtc_homekit.yml"
|
||||
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||
python3 -I /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown
|
||||
chown -h go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true
|
||||
python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown
|
||||
chown go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true
|
||||
else
|
||||
python3 -I /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}"
|
||||
python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}"
|
||||
fi
|
||||
|
||||
readonly config_path="/config"
|
||||
|
||||
@@ -124,7 +124,7 @@ fi
|
||||
mkdir -p /etc/letsencrypt/www 2>/dev/null || true
|
||||
|
||||
# nginx settings are read once; both templates consume them
|
||||
nginx_settings=$(python3 -I /usr/local/nginx/get_nginx_settings.py)
|
||||
nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py)
|
||||
|
||||
# build templates for optional FRIGATE_BASE_PATH environment variable
|
||||
echo "$nginx_settings" | \
|
||||
|
||||
@@ -8,14 +8,13 @@ from typing import Any
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
sys.path.insert(0, "/opt/frigate")
|
||||
from frigate.config.env import apply_config_env_vars, substitute_frigate_vars
|
||||
from frigate.const import (
|
||||
BIRDSEYE_PIPE,
|
||||
LIBAVFORMAT_VERSION_MAJOR,
|
||||
)
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||
from frigate.util.config import find_config_file, resolve_ffmpeg_path
|
||||
from frigate.util.env import apply_config_env_vars, substitute_frigate_vars
|
||||
from frigate.util.live_streams import raw_transcode_streams
|
||||
from frigate.util.services import (
|
||||
is_go2rtc_arbitrary_exec_allowed,
|
||||
is_restricted_go2rtc_source,
|
||||
@@ -175,17 +174,6 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
del go2rtc_config["streams"][name]
|
||||
continue
|
||||
|
||||
# add transcoded live streams; a user stream with the same name wins here and
|
||||
# fails Frigate's config validation
|
||||
transcoded_streams = raw_transcode_streams(config)
|
||||
|
||||
if transcoded_streams:
|
||||
if go2rtc_config.get("streams") is None:
|
||||
go2rtc_config["streams"] = {}
|
||||
|
||||
for name, source in transcoded_streams.items():
|
||||
go2rtc_config["streams"].setdefault(name, source)
|
||||
|
||||
# add birdseye restream stream if enabled
|
||||
if config.get("birdseye", {}).get("restream", False):
|
||||
birdseye: dict[str, Any] = config.get("birdseye")
|
||||
@@ -198,13 +186,9 @@ if config.get("birdseye", {}).get("restream", False):
|
||||
else:
|
||||
go2rtc_config["streams"] = {"birdseye": ffmpeg_cmd}
|
||||
|
||||
# The run script removes the old file first. Exclusive create never follows a
|
||||
# link the go2rtc user planted in sticky /dev/shm. The config contains camera
|
||||
# credentials, so it is not world-readable.
|
||||
fd = os.open(
|
||||
"/dev/shm/go2rtc.yaml",
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o640,
|
||||
)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
# Write go2rtc_config to /dev/shm/go2rtc.yaml
|
||||
with open("/dev/shm/go2rtc.yaml", "w") as f:
|
||||
yaml.dump(go2rtc_config, f)
|
||||
|
||||
# config contains camera credentials; do not leave it world-readable
|
||||
os.chmod("/dev/shm/go2rtc.yaml", 0o640)
|
||||
|
||||
@@ -14,12 +14,8 @@ location /auth {
|
||||
proxy_pass_request_headers off;
|
||||
# Pass info about the request
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
proxy_set_header X-Original-URL $request_uri;
|
||||
# set by the go2rtc stream locations; client headers are not forwarded, so
|
||||
# this cannot be spoofed
|
||||
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Server-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Content-Length "";
|
||||
# Pass along auth related info
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
|
||||
@@ -219,8 +219,6 @@ http {
|
||||
|
||||
# frigate lovelace card uses this path
|
||||
location /live/mse/api/ws {
|
||||
# tells /auth to apply the go2rtc stream checks
|
||||
set $go2rtc_stream_proxy 1;
|
||||
include auth_request.conf;
|
||||
limit_except GET {
|
||||
deny all;
|
||||
@@ -230,8 +228,6 @@ http {
|
||||
}
|
||||
|
||||
location /live/webrtc/api/ws {
|
||||
# tells /auth to apply the go2rtc stream checks
|
||||
set $go2rtc_stream_proxy 1;
|
||||
include auth_request.conf;
|
||||
limit_except GET {
|
||||
deny all;
|
||||
@@ -252,8 +248,6 @@ http {
|
||||
|
||||
# integration uses this to add webrtc candidate
|
||||
location /api/go2rtc/webrtc {
|
||||
# tells /auth to apply the go2rtc stream checks
|
||||
set $go2rtc_stream_proxy 1;
|
||||
include auth_request.conf;
|
||||
limit_except POST {
|
||||
deny all;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header X-Original-URL $request_uri;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-URI $request_uri;
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
sys.path.insert(0, "/opt/frigate")
|
||||
from frigate.util.config import find_config_file, parse_listen_address
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
sys.path.remove("/opt/frigate")
|
||||
|
||||
@@ -35,29 +35,20 @@ ipv6_config: dict[str, Any] = networking_config.get("ipv6", {})
|
||||
ipv6_config.setdefault("enabled", False)
|
||||
|
||||
listen_config: dict[str, Any] = networking_config.get("listen", {})
|
||||
listen_config.setdefault("internal", 5000)
|
||||
listen_config.setdefault("external", 8971)
|
||||
|
||||
# Frigate's own validation runs elsewhere, and a bad value here must not stop nginx
|
||||
# from serving the safe-mode UI. Valid values are kept so a fallback never widens
|
||||
# the unauthenticated internal listener beyond the configured address.
|
||||
for key, default in (("internal", 5000), ("external", 8971)):
|
||||
try:
|
||||
value, port = parse_listen_address(listen_config.get(key, default))
|
||||
except ValueError:
|
||||
print(
|
||||
f"[WARN] Invalid networking.listen.{key}, using {default}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
value, port = default, default
|
||||
# handle case where internal port is a string with ip:port
|
||||
internal_port = listen_config["internal"]
|
||||
if type(internal_port) is str:
|
||||
internal_port = int(internal_port.split(":")[-1])
|
||||
listen_config["internal_port"] = internal_port
|
||||
|
||||
listen_config[key] = value
|
||||
listen_config[f"{key}_port"] = port
|
||||
|
||||
if listen_config["internal_port"] == listen_config["external_port"]:
|
||||
print(
|
||||
"[ERROR] networking.listen.internal and external share a port",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
# handle case where external port is a string with ip:port
|
||||
external_port = listen_config["external"]
|
||||
if type(external_port) is str:
|
||||
external_port = int(external_port.split(":")[-1])
|
||||
listen_config["external_port"] = external_port
|
||||
|
||||
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
|
||||
|
||||
|
||||
+50
-11
@@ -2,9 +2,32 @@
|
||||
|
||||
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG ROCM=1
|
||||
ARG HSA_OVERRIDE_GFX_VERSION
|
||||
ARG HSA_OVERRIDE
|
||||
|
||||
#######################################################################
|
||||
FROM wget AS rocm
|
||||
|
||||
ARG ROCM
|
||||
|
||||
RUN apt update -qq && \
|
||||
apt install -y wget gpg && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2.3/ubuntu/jammy/amdgpu-install_7.2.3.70203-1_all.deb && \
|
||||
apt install -y ./rocm.deb && \
|
||||
apt update && \
|
||||
apt install -qq -y rocm
|
||||
|
||||
RUN mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib
|
||||
RUN cd /opt/rocm-$ROCM/lib && \
|
||||
cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocsolver*.so* librocfft*.so* librocprofiler*.so* libroctx*.so* librocroller.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/ && \
|
||||
mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib && \
|
||||
cp -dpr migraphx/lib/* /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib
|
||||
RUN cd /opt/rocm-dist/opt/ && ln -s rocm-$ROCM rocm
|
||||
|
||||
RUN mkdir -p /opt/rocm-dist/etc/ld.so.conf.d/
|
||||
RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf
|
||||
|
||||
#######################################################################
|
||||
FROM deps AS deps-prelim
|
||||
|
||||
@@ -19,35 +42,51 @@ RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.
|
||||
rm -f /etc/apt/sources.list.d/trixie.list && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/frigate
|
||||
COPY --from=rootfs / /
|
||||
|
||||
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
|
||||
&& python3 get-pip.py "pip" --break-system-packages
|
||||
RUN python3 -m pip config set global.break-system-packages true
|
||||
|
||||
# ROCm installs from AMD's wheels ahead of Frigate's code so code changes
|
||||
# reuse this layer, and is pruned in the same layer so the removed files never
|
||||
# reach the image. /opt/rocm links to the runtime for rocminfo and HIP headers.
|
||||
COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt
|
||||
RUN --mount=type=bind,source=docker/rocm/prune_rocm.sh,target=/deps/prune_rocm.sh \
|
||||
pip3 uninstall -y onnxruntime \
|
||||
&& pip3 install --no-cache-dir -r /requirements.txt \
|
||||
&& ROCM_SDK_CORE="$(python3 -c 'import _rocm_sdk_core, os; print(os.path.dirname(_rocm_sdk_core.__file__))')" \
|
||||
&& /deps/prune_rocm.sh "$(dirname "${ROCM_SDK_CORE}")" \
|
||||
&& ln -s "${ROCM_SDK_CORE}" /opt/rocm
|
||||
RUN pip3 uninstall -y onnxruntime \
|
||||
&& pip3 install -r /requirements.txt
|
||||
|
||||
WORKDIR /opt/frigate
|
||||
COPY --from=rootfs / /
|
||||
#######################################################################
|
||||
FROM scratch AS rocm-dist
|
||||
|
||||
ARG ROCM
|
||||
|
||||
# Copy HIP headers required for MIOpen JIT (BuildHip) / HIPRTC at runtime
|
||||
COPY --from=rocm /opt/rocm-${ROCM}/include/ /opt/rocm-${ROCM}/include/
|
||||
COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/
|
||||
# Copy MIOpen database files for gfx10xx, gfx11xx, and gfx12xx only (RDNA2/RDNA3/RDNA4)
|
||||
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx10* /opt/rocm-$ROCM/share/miopen/db/
|
||||
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx11* /opt/rocm-$ROCM/share/miopen/db/
|
||||
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx12* /opt/rocm-$ROCM/share/miopen/db/
|
||||
# Copy rocBLAS library files for gfx10xx, gfx11xx, and gfx12xx only
|
||||
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx10* /opt/rocm-$ROCM/lib/rocblas/library/
|
||||
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx11* /opt/rocm-$ROCM/lib/rocblas/library/
|
||||
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx12* /opt/rocm-$ROCM/lib/rocblas/library/
|
||||
COPY --from=rocm /opt/rocm-dist/ /
|
||||
|
||||
#######################################################################
|
||||
FROM deps-prelim AS rocm-prelim-hsa-override0
|
||||
ENV MIGRAPHX_DISABLE_MIOPEN_FUSION=1
|
||||
ENV MIGRAPHX_DISABLE_SCHEDULE_PASS=1
|
||||
ENV MIGRAPHX_DISABLE_REDUCE_FUSION=1
|
||||
ENV MIGRAPHX_ENABLE_HIPRTC_WORKAROUNDS=1
|
||||
ENV MIOPEN_CUSTOM_CACHE_DIR=/config/model_cache/migraphx
|
||||
ENV MIOPEN_USER_DB_PATH=/config/model_cache/migraphx
|
||||
ENV AMD_COMGR_CACHE=1
|
||||
ENV AMD_COMGR_CACHE_DIR=/config/model_cache/migraphx
|
||||
|
||||
COPY --from=rocm-dist / /
|
||||
|
||||
RUN ldconfig
|
||||
|
||||
#######################################################################
|
||||
FROM rocm-prelim-hsa-override0 as rocm-prelim-hsa-override1
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Remove the parts of AMD's ROCm wheels that MIGraphX inference never loads:
|
||||
# compilers, debuggers, profilers, management tools, and the math libraries
|
||||
# (sparse, solver, FFT, random, RCCL, image processing) outside MIGraphX's
|
||||
# dependencies. Runs in the same layer as the install so the files never
|
||||
# reach the image.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
site_packages="$1"
|
||||
core="${site_packages}/_rocm_sdk_core"
|
||||
libraries="${site_packages}/_rocm_sdk_libraries"
|
||||
|
||||
# rocminfo stays for checking that the GPU is visible in the container
|
||||
find "${core}/bin" -mindepth 1 -maxdepth 1 \
|
||||
! -name rocminfo ! -name rocm_agent_enumerator -exec rm -rf {} +
|
||||
|
||||
# hiprtc compiles MIGraphX's kernels through comgr, which needs libLLVM,
|
||||
# libclang-cpp, the clang resource headers, and the amdgcn device libraries
|
||||
rm -rf "${core}/lib/llvm/bin" "${core}/lib/llvm/include"
|
||||
find "${core}/lib/llvm/lib" -mindepth 1 -maxdepth 1 \
|
||||
! -name 'libLLVM.so*' ! -name 'libclang-cpp.so*' ! -name clang \
|
||||
-exec rm -rf {} +
|
||||
rm -rf "${core}"/lib/llvm/lib/clang/*/lib
|
||||
|
||||
rm -rf \
|
||||
"${core}/lib/host-math" \
|
||||
"${core}"/lib/python3* \
|
||||
"${core}/lib/rocprofiler-sdk" \
|
||||
"${core}"/lib/librocprofiler-sdk*.so* \
|
||||
"${core}"/lib/librocprof-trace-decoder.so* \
|
||||
"${core}"/lib/librocm-dbgapi.so* \
|
||||
"${core}"/lib/librocm-debug-agent.so* \
|
||||
"${core}"/lib/libamd_smi*.so* \
|
||||
"${core}"/lib/libgoamdsmi_shim64.so* \
|
||||
"${core}"/lib/librocdecode.so* \
|
||||
"${core}"/lib/librocjpeg.so* \
|
||||
"${core}"/lib/librocdxg.so* \
|
||||
"${core}"/lib/librocjitsu_*.so* \
|
||||
"${core}"/lib/libhsa_hotswap_rocjitsu.so* \
|
||||
"${core}"/lib/*.a
|
||||
|
||||
rm -rf \
|
||||
"${libraries}"/lib/librpp.so* \
|
||||
"${libraries}"/lib/librocsparse.so* \
|
||||
"${libraries}"/lib/libhipsparse.so* \
|
||||
"${libraries}"/lib/libhipsparselt.so* \
|
||||
"${libraries}/lib/rocsparse" \
|
||||
"${libraries}"/lib/librocsolver.so* \
|
||||
"${libraries}"/lib/libhipsolver.so* \
|
||||
"${libraries}"/lib/libhipblas.so* \
|
||||
"${libraries}"/lib/librocrand.so* \
|
||||
"${libraries}"/lib/libhiprand.so* \
|
||||
"${libraries}"/lib/librocfft.so* \
|
||||
"${libraries}"/lib/libhipfft.so* \
|
||||
"${libraries}"/lib/libhipfftw.so* \
|
||||
"${libraries}"/lib/librccl.so* \
|
||||
"${libraries}"/.kpack/sparse_lib_*.kpack \
|
||||
"${libraries}"/.kpack/solver_lib_*.kpack \
|
||||
"${libraries}"/.kpack/rand_lib_*.kpack \
|
||||
"${libraries}"/.kpack/fft_lib_*.kpack \
|
||||
"${libraries}"/.kpack/rccl_lib_*.kpack
|
||||
@@ -1,19 +1 @@
|
||||
# ROCm 10.1.0, MIGraphX, and its ONNX Runtime plugin provider from AMD's wheels.
|
||||
onnxruntime == 1.29.*
|
||||
onnxruntime-ep-migraphx @ https://stable.repo.amd.com/rocm/onnxruntime/whl-next/onnxruntime-ep-migraphx/onnxruntime_ep_migraphx-1.0.0%2Brocm10.1.0-cp311-cp311-manylinux_2_28_x86_64.whl
|
||||
migraphx-libs @ https://stable.repo.amd.com/rocm/migraphx/whl-next/migraphx-libs/migraphx_libs-2.18.0%2Brocm10.1.0-py3-none-manylinux_2_28_x86_64.whl
|
||||
rocm-sdk-core @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-core/rocm_sdk_core-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-libraries @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-libraries/rocm_sdk_libraries-10.1.0-py3-none-linux_x86_64.whl
|
||||
# Kernels for each supported GPU target. MIGraphX has no gfx1031-gfx1036 kernels,
|
||||
# so those GPUs run as gfx1030 with HSA_OVERRIDE_GFX_VERSION=10.3.0
|
||||
rocm-sdk-device-gfx1030 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1030/rocm_sdk_device_gfx1030-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1100 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1100/rocm_sdk_device_gfx1100-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1101 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1101/rocm_sdk_device_gfx1101-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1102 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1102/rocm_sdk_device_gfx1102-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1103 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1103/rocm_sdk_device_gfx1103-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1150 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1150/rocm_sdk_device_gfx1150-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1151 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1151/rocm_sdk_device_gfx1151-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1152 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1152/rocm_sdk_device_gfx1152-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1153 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1153/rocm_sdk_device_gfx1153-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1200 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1200/rocm_sdk_device_gfx1200-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1201 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1201/rocm_sdk_device_gfx1201-10.1.0-py3-none-linux_x86_64.whl
|
||||
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.3-1/onnxruntime_migraphx-1.24.4-cp311-cp311-linux_x86_64.whl
|
||||
@@ -1,3 +1,6 @@
|
||||
variable "ROCM" {
|
||||
default = "7.2.3"
|
||||
}
|
||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||
default = ""
|
||||
}
|
||||
@@ -5,6 +8,12 @@ variable "HSA_OVERRIDE" {
|
||||
default = "1"
|
||||
}
|
||||
|
||||
target wget {
|
||||
dockerfile = "docker/main/Dockerfile"
|
||||
platforms = ["linux/amd64"]
|
||||
target = "wget"
|
||||
}
|
||||
|
||||
target deps {
|
||||
dockerfile = "docker/main/Dockerfile"
|
||||
platforms = ["linux/amd64"]
|
||||
@@ -21,10 +30,12 @@ target rocm {
|
||||
dockerfile = "docker/rocm/Dockerfile"
|
||||
contexts = {
|
||||
deps = "target:deps",
|
||||
wget = "target:wget",
|
||||
rootfs = "target:rootfs"
|
||||
}
|
||||
platforms = ["linux/amd64"]
|
||||
args = {
|
||||
ROCM = ROCM,
|
||||
HSA_OVERRIDE_GFX_VERSION = HSA_OVERRIDE_GFX_VERSION,
|
||||
HSA_OVERRIDE = HSA_OVERRIDE
|
||||
}
|
||||
|
||||
@@ -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==7.36.*; platform_machine == 'x86_64'
|
||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||
|
||||
@@ -824,38 +824,6 @@ cpu:
|
||||
models:
|
||||
- devices:
|
||||
- cpu:3
|
||||
xdna2:
|
||||
title: AMD XDNA2
|
||||
models:
|
||||
- key: yolov9
|
||||
label: YOLOv9
|
||||
recommended: true
|
||||
download: |-
|
||||
Prepare the model using the frigate-xdna setup instructions linked above. For local YOLO models, Frigate must have access to the same ONNX file bytes as the sidecar. The example below uses YOLOv9-C at 320x320. Frigate+ models may instead use the same `plus://MODEL_ID` in Frigate and the sidecar.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and add a model. The ZMQ endpoint is not reported by the hardware probe, so set `devices` to `zmq:tcp://xdna:5555` in YAML. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------ |
|
||||
| **Custom object detector model path** | `/config/models/yolov9-c-320.onnx` |
|
||||
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
|
||||
| **Object detection model input width** | `320` |
|
||||
| **Object detection model input height** | `320` |
|
||||
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
|
||||
| **Model Input Tensor Shape** | `nchw` |
|
||||
| **Model Input D Type** | `float` |
|
||||
| **Object Detection Model Type** | `yolo-generic` |
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
- zmq:tcp://xdna:5555
|
||||
model_type: yolo-generic
|
||||
width: 320
|
||||
height: 320
|
||||
input_tensor: nchw
|
||||
input_dtype: float
|
||||
path: /config/models/yolov9-c-320.onnx
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
memryx:
|
||||
title: MemryX
|
||||
models:
|
||||
|
||||
@@ -117,9 +117,7 @@ auth:
|
||||
# Optional: Cookie to store the JWT token for native auth (default: shown below)
|
||||
cookie_name: frigate_token
|
||||
# Optional: Set secure flag on cookie. (default: shown below)
|
||||
# NOTE: Frigate sets the flag automatically when serving TLS. Set this to True
|
||||
# when a reverse proxy terminates TLS in front of Frigate. A proxy that connects
|
||||
# to the TLS port must serve Frigate to browsers over HTTPS
|
||||
# NOTE: This should be set to True if you are using TLS
|
||||
cookie_secure: False
|
||||
# Optional: Session length in seconds (default: shown below)
|
||||
session_length: 86400 # 24 hours
|
||||
@@ -154,10 +152,9 @@ auth:
|
||||
models:
|
||||
# Optional: the camera environment this model is for (default: shown below)
|
||||
# Cameras select a model by setting detect -> scene to a matching value, and
|
||||
# the model with a scene of default is used by any camera that does not set one.
|
||||
# Any name made up of letters, numbers, _ and - is valid, such as thermal.
|
||||
# Models that use the same model file are combined into one model.
|
||||
- scene: default
|
||||
# a model with a scene of all is used by any camera that does not set one.
|
||||
# Valid values are all, indoor, outdoor, indoor_thermal, outdoor_thermal
|
||||
- scene: all
|
||||
# Required: hardware this model runs on, as <detector> or <detector>:<device>
|
||||
# See https://docs.frigate.video/configuration/object_detectors for the
|
||||
# detectors available and the devices each one accepts. All of a model's
|
||||
@@ -296,9 +293,9 @@ ffmpeg:
|
||||
# Optional: output args for detect streams (default: shown below)
|
||||
detect: -threads 2 -f rawvideo -pix_fmt yuv420p
|
||||
# Optional: output args for record streams (default: shown below)
|
||||
record: preset-record-generic-audio-aac
|
||||
record: preset-record-generic
|
||||
# Optional: output args for sub stream record streams (default: the record output args above)
|
||||
# record_sub: preset-record-generic-audio-aac
|
||||
# record_sub: preset-record-generic
|
||||
# Optional: Time in seconds to wait before ffmpeg retries connecting to the camera. (default: shown below)
|
||||
# If set too low, frigate will retry a connection to the camera's stream too frequently, using up the limited streams some cameras can allow at once
|
||||
# If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage
|
||||
@@ -319,9 +316,9 @@ detect:
|
||||
# Optional: height of the frame for the input with the detect role (default: use native stream resolution)
|
||||
height: 720
|
||||
# Optional: the environment this camera looks at, which picks the model it runs on
|
||||
# (default: the model with a scene of default)
|
||||
# Must match the scene of a configured model
|
||||
scene: thermal
|
||||
# (default: the model with a scene of all)
|
||||
# Valid values are all, indoor, outdoor, indoor_thermal, outdoor_thermal
|
||||
scene: outdoor
|
||||
# Optional: desired fps for your camera for the input with the detect role (default: shown below)
|
||||
# NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera.
|
||||
fps: 5
|
||||
@@ -558,7 +555,7 @@ motion:
|
||||
# Optional: Whether this mask is active (default: true)
|
||||
enabled: true
|
||||
# Required: Coordinates polygon for the mask
|
||||
coordinates: "0.000,0.469,1.0,0.469,1.0,1.0,0.000,1.0"
|
||||
coordinates: "0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000"
|
||||
# Optional: improve contrast (default: shown below)
|
||||
# Enables dynamic contrast improvement. This should help improve night detections at the cost of making motion detection more sensitive
|
||||
# for daytime.
|
||||
@@ -573,8 +570,6 @@ notifications:
|
||||
enabled: False
|
||||
# Optional: Email for push service to reach out to
|
||||
# NOTE: This is required to use notifications
|
||||
# NOTE: Email can be specified with an environment variable or docker secrets that must begin with 'FRIGATE_'.
|
||||
# e.g. email: '{FRIGATE_NOTIFICATION_EMAIL}'
|
||||
email: "admin@example.com"
|
||||
# Optional: Cooldown time for notifications in seconds (default: shown below)
|
||||
cooldown: 0
|
||||
@@ -905,21 +900,6 @@ live:
|
||||
streams:
|
||||
main_stream: main_stream_name
|
||||
sub_stream: sub_stream_name
|
||||
# Optional: Lower-quality live streams transcoded by go2rtc while someone is watching.
|
||||
# NOTE: Set at the camera level only.
|
||||
transcode:
|
||||
# Optional: Enable transcoded streams (default: shown below)
|
||||
enabled: False
|
||||
# Optional: go2rtc stream to transcode (default: the first live stream)
|
||||
source: main_stream_name
|
||||
# Optional: One transcoded stream per quality (default: shown below)
|
||||
qualities:
|
||||
- height: 720
|
||||
bitrate: 1200
|
||||
- height: 480
|
||||
bitrate: 500
|
||||
- height: 360
|
||||
bitrate: 250
|
||||
# Optional: Set the height of the jsmpeg stream. (default: 720)
|
||||
# This must be less than or equal to the height of the detect stream. Lower resolutions
|
||||
# reduce bandwidth required for viewing the jsmpeg stream. Width is computed to match known aspect ratio.
|
||||
|
||||
@@ -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. 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.
|
||||
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.
|
||||
|
||||
To use a GenAI provider for audio transcription:
|
||||
|
||||
|
||||
@@ -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. 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.or
|
||||
| Amcrest ASH21 | ✅ | ❌ | ONVIF service port: 80 |
|
||||
| Amcrest IP4M-S2112EW-AI | ✅ | ❌ | FOV relative movement not supported. |
|
||||
| Amcrest IP5M-1190EW | ✅ | ❌ | ONVIF Port: 80. FOV relative movement not supported. |
|
||||
| Annke CZ504 | ✅ | ❌ | |
|
||||
| Annke CZ504 | ✅ | ✅ | Annke support provide specific firmware ([V5.7.1 build 250227](https://github.com/pierrepinon/annke_cz504/raw/refs/heads/main/digicap_V5-7-1_build_250227.dav)) to fix issue with ONVIF "TranslationSpaceFov" |
|
||||
| Axis Q-6155E | ✅ | ❌ | ONVIF service port: 80; Camera does not support MoveStatus. |
|
||||
| Ctronics PTZ | ✅ | ❌ | |
|
||||
| Dahua | ✅ | ✅ | Some low-end Dahuas (lite series, picoo series (commonly), among others) have been reported to not support autotracking. These models usually don't have a four digit model number with chassis prefix and options postfix (e.g. DH-P5AE-PV vs DH-SD49825GB-HNR). |
|
||||
|
||||
@@ -85,14 +85,6 @@ An optional config, `save_attempts`, can be set as a key under the model name. T
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
## Review items
|
||||
|
||||
When a model's state changes while its camera has an active review item, the change is recorded on that review item. This includes changes in the few seconds before the item starts, such as a garage door opening just before the car is detected. State changes never create or extend review items on their own, and the first state reported after Frigate starts is not recorded as a change.
|
||||
|
||||
Recorded changes appear in the review item's data as `classification_state_changes` (see the [`frigate/reviews`](/integrations/mqtt#frigatereviews) MQTT topic) and are passed to [GenAI review summaries](/configuration/genai/genai_review) as facts, so a description can note that a gate was opened during the activity.
|
||||
|
||||
Change times are most accurate with `motion: true`. A model that only runs on an `interval` notices a change at its next run, so the change may be recorded late or attached to a later review item.
|
||||
|
||||
## Training the model
|
||||
|
||||
Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of three steps:
|
||||
|
||||
@@ -17,19 +17,17 @@ Hardware acceleration arguments tell FFmpeg to decode your camera's video stream
|
||||
|
||||
See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md) for details on setting up hardware acceleration for your GPU / iGPU, then select the preset that matches your hardware.
|
||||
|
||||
| Preset (YAML config) | UI Label | Usage | Notes |
|
||||
| ------------------------- | ----------------------- | --------------------------------------------- | --------------------------------------------------------------- |
|
||||
| preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | |
|
||||
| preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | |
|
||||
| preset-apple-silicon-h264 | Apple Silicon (H.264) | Apple Silicon Mac under lighter, H.264 stream | Needs the `lighter.sh/video` device |
|
||||
| preset-apple-silicon-h265 | Apple Silicon (H.265) | Apple Silicon Mac under lighter, H.265 stream | Needs the `lighter.sh/video` device |
|
||||
| preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected |
|
||||
| preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead |
|
||||
| preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead |
|
||||
| preset-nvidia | NVIDIA GPU | NVIDIA GPU | |
|
||||
| preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | |
|
||||
| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | |
|
||||
| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode |
|
||||
| Preset (YAML config) | UI Label | Usage | Notes |
|
||||
| --------------------- | ----------------------- | --------------------------------- | --------------------------------------------------------------- |
|
||||
| preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | |
|
||||
| preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | |
|
||||
| preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected |
|
||||
| preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead |
|
||||
| preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead |
|
||||
| preset-nvidia | NVIDIA GPU | NVIDIA GPU | |
|
||||
| preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | |
|
||||
| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | |
|
||||
| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode |
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
@@ -43,19 +43,7 @@ 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. 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.
|
||||
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.
|
||||
|
||||
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_`.
|
||||
|
||||
@@ -85,10 +73,9 @@ 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 |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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. |
|
||||
| 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`. |
|
||||
|
||||
#### Transcription models
|
||||
|
||||
@@ -165,10 +152,6 @@ 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.
|
||||
@@ -214,10 +197,6 @@ 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.
|
||||
|
||||
@@ -201,8 +201,6 @@ Review items are sent to the model as a sequence of still frames. Some models fo
|
||||
|
||||
The notes come from tracking data rather than from the images, so they describe activity the model may not have picked up on its own. In testing with a person carrying three waste bins to the curb one at a time, `gemma4` described a single trip on every attempt with `frames`, and consistently described multiple trips with `annotated_frames`. Models that already handle these sequences well, such as the `qwen3-vl` family, gain little and should stay on `frames`.
|
||||
|
||||
Changes reported by [state classification](/configuration/custom_classification/state_classification#review-items) models during the review item are listed in the prompt in both modes. `annotated_frames` also notes each change before the frame where it happened.
|
||||
|
||||
Annotated mode also caps the number of frames, since the notes already establish the order of events and extra near-duplicate frames tend to crowd out the middle of a clip. Longer review items are sampled more sparsely as a result, and typically use fewer tokens than `frames` mode for the same item.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -43,10 +43,6 @@ Frigate supports presets for optimal hardware accelerated video decoding:
|
||||
|
||||
- [RKNN](#rockchip-platform): Frigate can utilize the media engine in RockChip SOCs to accelerate video decoding.
|
||||
|
||||
**Apple Silicon Mac** <CommunityBadge />
|
||||
|
||||
- [lighter](#apple-silicon-mac-lighter): Frigate can utilize the media engine in Apple Silicon Macs to accelerate video decoding, when running under the lighter container runtime.
|
||||
|
||||
**Other Hardware**
|
||||
|
||||
Depending on your system, these presets may not be compatible, and you may need to use manual hwaccel args to take advantage of your hardware. More information on hardware accelerated decoding for ffmpeg can be found here: https://trac.ffmpeg.org/wiki/HWAccelIntro
|
||||
@@ -136,8 +132,7 @@ ffmpeg:
|
||||
|
||||
Frigate reads Intel GPU utilization directly from the kernel's per-client DRM usage counters exposed at `/proc/<pid>/fdinfo/<fd>`. This requires:
|
||||
|
||||
- An Intel GPU from Broadwell (5th gen) or newer. Older GPUs do not expose these counters on any kernel.
|
||||
- Linux kernel **5.19 or newer** for the `i915` driver (**6.5 or newer** on 12th gen and newer), or any release of the `xe` driver.
|
||||
- Linux kernel **5.19 or newer** for the `i915` driver, or any release of the `xe` driver.
|
||||
- Frigate running with permission to read other processes' fdinfo. Running as root inside the container (the default) satisfies this; non-root setups may need `CAP_SYS_PTRACE`.
|
||||
|
||||
No `intel_gpu_top` binary, `CAP_PERFMON`, privileged mode, or `perf_event_paranoid` tuning is required.
|
||||
@@ -538,35 +533,3 @@ output_args:
|
||||
Make sure that your SoC supports hardware acceleration for your input stream and your input stream is h264 encoding. For example, if your camera streams with h264 encoding, your SoC must be able to de- and encode with it. If you are unsure whether your SoC meets the requirements, take a look at the datasheet.
|
||||
|
||||
:::
|
||||
|
||||
## Apple Silicon Mac (lighter)
|
||||
|
||||
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. It gives a container the Mac's media engine as a standard V4L2 decoder, backed by VideoToolbox, so Frigate decodes H.264 and H.265 streams in hardware with the ffmpeg it already ships. It works on M1 and newer Macs with lighter 0.9.2 or newer.
|
||||
|
||||
Give the container the video device. With Docker Compose:
|
||||
|
||||
```yaml {4-5}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
devices:
|
||||
- lighter.sh/video=all
|
||||
```
|
||||
|
||||
Or with `docker run`, add `--device lighter.sh/video=all`.
|
||||
|
||||
Then set the preset for the codec your cameras stream. The decoder is specific to the codec, so if your cameras mix H.264 and H.265, set the preset for the most common codec globally and override it on the other cameras:
|
||||
|
||||
```yaml
|
||||
ffmpeg:
|
||||
hwaccel_args: preset-apple-silicon-h264
|
||||
|
||||
cameras:
|
||||
garage: # an H.265 camera
|
||||
ffmpeg:
|
||||
hwaccel_args: preset-apple-silicon-h265
|
||||
```
|
||||
|
||||
The presets decode on the media engine and encode the Birdseye restream and timelapses there too. Scaling to the detect resolution runs on the CPU, as ffmpeg's V4L2 decoders cannot scale.
|
||||
|
||||
lighter can also run object detection on the Mac's Neural Engine; see [Apple Neural Engine (lighter)](object_detectors.md#apple-neural-engine-lighter).
|
||||
|
||||
@@ -378,10 +378,10 @@ Navigate to <NavPath path="Settings > Camera configuration > Object detection" /
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------------------------------------- | ------------------- |
|
||||
| **Objects to track** | Add `license_plate` |
|
||||
| **Object filters > License Plate > Confidence threshold** | Set to `0.7` |
|
||||
| Field | Description |
|
||||
| ---------------------------------------------- | ------------------- |
|
||||
| **Objects to track** | Add `license_plate` |
|
||||
| **Object filters > License Plate > Threshold** | Set to `0.7` |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ Frigate [defaults to MSE](#why-does-frigate-prefer-mse-over-webrtc-for-live-view
|
||||
|
||||
- The choices are saved **per device, per camera** in your browser's local storage.
|
||||
- **WebRTC is only selectable when it can actually work for that stream.** When it can't, the option is shown disabled with the reason inline, and a more detailed reason (the failing codecs, or why the connectivity check failed) is logged to your browser's console. Common reasons:
|
||||
- **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc`, and the connectivity check failed with go2rtc's default candidates (see [WebRTC extra configuration](#webrtc-extra-configuration)). This is a setup note rather than an error, since live view works without WebRTC.
|
||||
- **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc` (see [WebRTC extra configuration](#webrtc-extra-configuration)).
|
||||
- **Could not connect**: e.g. port `8555` isn't reachable, or a STUN/TURN server is misconfigured. Frigate runs a one-time WebRTC connectivity check when the Live view opens; the option may briefly show as "checking" while it runs.
|
||||
- **Unsupported video codec**: the stream's video codec can't be played over WebRTC in your browser, most commonly H.265/HEVC in Firefox or Edge.
|
||||
- **Unsupported audio codec**: WebRTC needs opus or G.711 audio, so a stream whose playback audio is only AAC (without an added opus/G.711 track) can't carry audio over WebRTC. See [Audio Support](#audio-support) for how to add one.
|
||||
@@ -92,7 +92,7 @@ go2rtc:
|
||||
|
||||
### Setting Streams For Live UI
|
||||
|
||||
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage. When a camera has more than one stream, the dropdown also offers **Auto**, which is used until you pick a specific stream. Auto starts on the first stream, steps down the list when your connection can't keep up, and steps back up when it recovers. To retry the top stream right away, select **Try highest quality** under the stream picker. List streams from highest to lowest quality, and avoid names that are plain numbers (such as `720`), which the browser sorts ahead of the others. In the UI, drag streams to reorder them, or use **Auto order** to sort them by measured bitrate.
|
||||
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage.
|
||||
|
||||
Additionally, when creating and editing camera groups in the UI, you can choose the stream you want to use for your camera group's Live dashboard.
|
||||
|
||||
@@ -158,26 +158,6 @@ cameras:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Transcoded streams
|
||||
|
||||
When a camera has no suitable sub stream, Frigate can add lower-quality streams that go2rtc transcodes to H.264 while someone is watching. They appear in the stream list like any other stream, so Auto mode can step down to them. Enable them under <NavPath path="Settings > Camera configuration > Live playback" />, or in YAML:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
test_cam:
|
||||
live:
|
||||
transcode:
|
||||
enabled: true
|
||||
source: test_cam # optional, defaults to the first live stream
|
||||
qualities:
|
||||
- height: 720
|
||||
bitrate: 1200 # kbps
|
||||
- height: 480
|
||||
bitrate: 500
|
||||
```
|
||||
|
||||
Each quality becomes a go2rtc stream named `<camera>_transcode_<height>p`. go2rtc picks a hardware encoder automatically and falls back to the CPU, which costs CPU for each transcode while it is being watched. Check go2rtc's `api/ffmpeg/hardware` page to see which encoder it found. Using a sub stream as the `source` lowers the cost.
|
||||
|
||||
### WebRTC extra configuration:
|
||||
|
||||
WebRTC works by creating a TCP or UDP connection on port `8555`. However, it requires additional configuration:
|
||||
@@ -383,13 +363,6 @@ When your browser runs into problems playing back your camera streams, it will l
|
||||
- `Safari reported InvalidStateError.`
|
||||
- `Safari reported decoding errors.`
|
||||
|
||||
- **mse-codec**
|
||||
- What it means: go2rtc has no codec for this stream that the browser can play.
|
||||
- What to try: Pick a stream with a codec the browser supports (H.264 is the most compatible), or use a browser that supports the stream's codec. In Auto, Frigate skips this stream for the rest of the session.
|
||||
|
||||
- Possible console messages from the player code:
|
||||
- `mse: streams: codecs not matched: ...`
|
||||
|
||||
- **stalled**
|
||||
- What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving).
|
||||
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in <NavPath path="Settings > UI" /> .
|
||||
|
||||
@@ -82,13 +82,13 @@ objects:
|
||||
person_filter1:
|
||||
friendly_name: "Roof area"
|
||||
enabled: true
|
||||
coordinates: "0.000,0.000,1.0,0.000,1.0,0.400,0.000,0.400"
|
||||
coordinates: "0.000,0.000,1.000,0.000,1.000,0.400,0.000,0.400"
|
||||
car:
|
||||
mask:
|
||||
car_filter1:
|
||||
friendly_name: "Sidewalk area"
|
||||
enabled: true
|
||||
coordinates: "0.000,0.700,1.0,0.700,1.0,1.0,0.000,1.0"
|
||||
coordinates: "0.000,0.700,1.000,0.700,1.000,1.000,0.000,1.000"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -30,12 +30,10 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
- [ROCm](#amdrocm-gpu-detector): ROCm can run on AMD Discrete GPUs to provide efficient object detection.
|
||||
- [ONNX](#onnx): ROCm will automatically be detected and used as a detector in the `-rocm` Frigate image when a supported ONNX model is configured.
|
||||
- <CommunityBadge /> [XDNA2](#amd-xdna2): AMD Ryzen AI / XDNA2 NPUs can run object detection through the community-maintained `frigate-xdna` ZMQ sidecar.
|
||||
|
||||
**Apple Silicon**
|
||||
|
||||
- [Apple Silicon](#apple-silicon-detector): Apple Silicon can run on M1 and newer Apple Silicon devices.
|
||||
- <CommunityBadge /> [ONNX](#apple-neural-engine-lighter): the ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs under the lighter container runtime.
|
||||
|
||||
**Intel**
|
||||
|
||||
@@ -104,36 +102,32 @@ Coral EdgeTPU and MemryX accelerators can only be opened by one process, so thos
|
||||
|
||||
### Running more than one model
|
||||
|
||||
Cameras can be split across models by scene, which is useful when some cameras benefit from a differently trained model, such as thermal cameras. Each model declares the `scene` it is for, and each camera picks one with `detect -> scene`:
|
||||
Cameras can be split across models by scene, which is useful when indoor and outdoor cameras benefit from differently trained models. Each model declares the `scene` it is for, and each camera picks one with `detect -> scene`:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- scene: default
|
||||
path: plus://your-model
|
||||
- scene: outdoor
|
||||
path: plus://your-outdoor-model
|
||||
devices:
|
||||
- edgetpu:pci:0
|
||||
- scene: thermal
|
||||
path: /config/model_cache/thermal.onnx
|
||||
- scene: indoor
|
||||
path: /config/model_cache/indoor.onnx
|
||||
model_type: yolo-generic
|
||||
devices:
|
||||
- openvino:GPU
|
||||
|
||||
cameras:
|
||||
driveway:
|
||||
...
|
||||
backyard_thermal:
|
||||
detect:
|
||||
scene: thermal
|
||||
scene: outdoor
|
||||
...
|
||||
hallway:
|
||||
detect:
|
||||
scene: indoor
|
||||
...
|
||||
```
|
||||
|
||||
A scene is any name made up of letters, numbers, `_`, and `-`. The model with a scene of `default` is used by every camera that does not set one (or sets a scene that no model is configured for), and `default` is used when a model does not declare a scene. Changing a camera's scene requires a restart.
|
||||
|
||||
:::warning
|
||||
|
||||
Scenes are for running **different** models. Do not configure the same model under several scenes to dedicate a detector to specific cameras: every detector of a model already serves every camera using it, and splitting them only leaves some detectors idle while others fall behind. Frigate detects models that use the same model file, even under a different path or file name, combines them into one model with all of their hardware, and logs a warning.
|
||||
|
||||
:::
|
||||
Available scenes are `all`, `indoor`, `outdoor`, `indoor_thermal`, and `outdoor_thermal`. A model with a scene of `all` is used by every camera that does not set one, and `all` is the default when a model does not declare a scene. Changing a camera's scene requires a restart.
|
||||
|
||||
### Choosing a model size
|
||||
|
||||
@@ -394,22 +388,6 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
|
||||
|
||||
Support for AMD GPUs is provided using the [ONNX detector](#onnx). In order to utilize the AMD GPU for object detection use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`.
|
||||
|
||||
### Supported GPUs {#supported-gpus-rocm}
|
||||
|
||||
The `-rocm` image includes ROCm support for these GPU chipsets, which work without any chipset override:
|
||||
|
||||
| Chipset | Example GPUs |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| gfx1030 (RDNA2) | Radeon RX 6800 / 6800 XT / 6900 XT / 6950 XT, Radeon PRO W6800 |
|
||||
| gfx1100, gfx1101, gfx1102 (RDNA3) | Radeon RX 7900 / 7800 / 7700 / 7600 series, Radeon PRO W7000 series |
|
||||
| gfx1103 (RDNA3 integrated) | Radeon 780M / 760M in Ryzen 7040, 8040, 8000G, and 200 series processors |
|
||||
| gfx1150, gfx1151, gfx1152, gfx1153 (RDNA3.5) | Integrated GPUs in Ryzen AI 300 / 400 series and Ryzen AI Max processors |
|
||||
| gfx1200, gfx1201 (RDNA4) | Radeon RX 9060 / 9070 series, Radeon AI PRO R9700 |
|
||||
|
||||
Other RDNA2 GPUs (gfx1031 through gfx1036), such as the Radeon RX 6700 / 6600 / 6500 series and the Radeon 680M / 660M integrated GPUs, run on the gfx1030 support by [overriding the chipset](#docker-settings-for-overriding-the-gpu-chipset) to `10.3.0`.
|
||||
|
||||
Older GPUs, including RDNA1 (Radeon RX 5000 series) and Vega based dedicated and integrated GPUs, are not supported.
|
||||
|
||||
### Docker settings for GPU access
|
||||
|
||||
ROCm needs access to the `/dev/kfd` and `/dev/dri` devices. When docker or frigate is not run under root then also `video` (and possibly `render` and `ssl/_ssl`) groups should be added.
|
||||
@@ -436,10 +414,19 @@ For reference on recommended settings see [running ROCm/pytorch in Docker](https
|
||||
|
||||
### Docker settings for overriding the GPU chipset
|
||||
|
||||
A GPU whose chipset isn't in the [supported GPUs](#supported-gpus-rocm) list can run on the support for a closely related chipset by setting the `HSA_OVERRIDE_GFX_VERSION` environment variable. RDNA2 GPUs other than gfx1030 (gfx1031 through gfx1036) need this, using `10.3.0`:
|
||||
Your GPU might work just fine without any special configuration but in many cases they need manual settings. AMD/ROCm software stack comes with a limited set of GPU drivers and for newer or missing models you will have to override the chipset version to an older/generic version to get things working.
|
||||
|
||||
Also AMD/ROCm does not "officially" support integrated GPUs. It still does work with most of them just fine but requires special settings. One has to configure the `HSA_OVERRIDE_GFX_VERSION` environment variable. See the [ROCm bug report](https://github.com/ROCm/ROCm/issues/1743) for context and examples.
|
||||
|
||||
For the rocm frigate build there is some automatic detection:
|
||||
|
||||
- gfx1031 -> 10.3.0
|
||||
- gfx1103 -> 11.0.0
|
||||
|
||||
If you have something else you might need to override the `HSA_OVERRIDE_GFX_VERSION` at Docker launch. Suppose the version you want is `10.0.0`, then you should configure it from command line as:
|
||||
|
||||
```bash
|
||||
$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.3.0 \
|
||||
$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.0.0 \
|
||||
...
|
||||
```
|
||||
|
||||
@@ -450,17 +437,16 @@ services:
|
||||
frigate:
|
||||
...
|
||||
environment:
|
||||
HSA_OVERRIDE_GFX_VERSION: "10.3.0"
|
||||
HSA_OVERRIDE_GFX_VERSION: "10.0.0"
|
||||
```
|
||||
|
||||
GPUs in the supported list don't need an override. An override that was set for the Radeon 780M / 760M (gfx1103) on an earlier version of Frigate is no longer needed and can be removed.
|
||||
Figuring out what version you need can be complicated as you can't tell the chipset name and driver from the AMD brand name.
|
||||
|
||||
You can't tell the chipset from the AMD brand name, so check it in the container:
|
||||
|
||||
- first make sure that the ROCm environment is running properly by running `/opt/rocm/bin/rocminfo` in the Frigate container. It should list both the CPU and the GPU with their properties
|
||||
- find the chipset version you have (gfxNNN) from the output of `rocminfo` (see below)
|
||||
- if the chipset isn't in the supported list, set `HSA_OVERRIDE_GFX_VERSION` to the closest supported chipset, written as major.minor.stepping (for example gfx1030 is `10.3.0`)
|
||||
- if things are not working check the Frigate docker logs
|
||||
- first make sure that rocm environment is running properly by running `/opt/rocm/bin/rocminfo` in the frigate container -- it should list both the CPU and the GPU with their properties
|
||||
- find the chipset version you have (gfxNNN) from the output of the `rocminfo` (see below)
|
||||
- use a search engine to query what `HSA_OVERRIDE_GFX_VERSION` you need for the given gfx name ("gfxNNN ROCm HSA_OVERRIDE_GFX_VERSION")
|
||||
- override the `HSA_OVERRIDE_GFX_VERSION` with relevant value
|
||||
- if things are not working check the frigate docker logs
|
||||
|
||||
#### Figuring out if AMD/ROCm is working and found your GPU
|
||||
|
||||
@@ -498,7 +484,7 @@ See [ONNX supported models](#onnx) for supported models, there are some caveats:
|
||||
|
||||
## ONNX
|
||||
|
||||
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, TensorRT, and a Mac's Neural Engine. On startup Frigate will automatically try to use a GPU if one is available.
|
||||
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, and TensorRT. On startup Frigate will automatically try to use a GPU if one is available.
|
||||
|
||||
:::info
|
||||
|
||||
@@ -514,9 +500,6 @@ If the correct build is used for your GPU then the GPU will be detected and used
|
||||
- Nvidia GPUs will automatically be detected and used with the ONNX detector in the `-tensorrt` Frigate image.
|
||||
- Jetson devices will automatically be detected and used with the ONNX detector in the `-tensorrt-jp6` Frigate image.
|
||||
|
||||
- **Apple Silicon Mac** <CommunityBadge />
|
||||
- The Neural Engine will automatically be detected and used with the ONNX detector when Frigate runs under lighter with its Neural Engine device. See [Apple Neural Engine (lighter)](#apple-neural-engine-lighter).
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
@@ -532,22 +515,6 @@ models:
|
||||
|
||||
:::
|
||||
|
||||
### Apple Neural Engine (lighter) {#apple-neural-engine-lighter}
|
||||
|
||||
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. A container started with its `lighter.sh/ane` device gets an ONNX Runtime execution provider that runs models on the Mac's Neural Engine, and the ONNX detector uses it automatically, with the same models and configuration as on any other hardware. It works on M1 and newer Macs with lighter 0.9.2 or newer.
|
||||
|
||||
Give the Frigate container the Neural Engine device. With Docker Compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
image: ghcr.io/blakeblackshear/frigate:stable-standard-arm64
|
||||
devices:
|
||||
- lighter.sh/ane=all
|
||||
```
|
||||
|
||||
Or with `docker run`, add `--device lighter.sh/ane=all`. Frigate then reports the Neural Engine under **Settings > System > Detection models**, and the ONNX detector's model loads on it. lighter can also decode camera streams on the Mac's media engine; see [Video Decoding](hardware_acceleration_video.md#apple-silicon-mac-lighter).
|
||||
|
||||
### Configuration {#configuration-onnx}
|
||||
|
||||
<ModelConfigDropdown detectorTitle="ONNX" models={objectDetectorsModels.onnx.models} />
|
||||
@@ -576,28 +543,6 @@ When using CPU detectors, you can add one CPU detector per camera. Adding more d
|
||||
|
||||
# Community Supported Detectors
|
||||
|
||||
## AMD XDNA2
|
||||
|
||||
AMD Ryzen AI / XDNA2 NPUs can be used through the community-maintained
|
||||
[frigate-xdna](https://github.com/mitchins/frigate-xdna) detector sidecar.
|
||||
The sidecar runs separately from Frigate and connects using Frigate's ZMQ
|
||||
detector interface.
|
||||
|
||||
Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2 devices
|
||||
are not yet qualified; XDNA1 is unsupported.
|
||||
|
||||
Follow the frigate-xdna setup instructions to prepare and start the sidecar
|
||||
before starting Frigate.
|
||||
|
||||
### Configuration {#configuration-xdna2}
|
||||
|
||||
Using the detector config below will connect Frigate to the sidecar:
|
||||
|
||||
<ModelConfigDropdown detectorTitle="AMD XDNA2" models={objectDetectorsModels.xdna2.models} />
|
||||
|
||||
The example assumes Frigate and the sidecar share a Docker network where the
|
||||
sidecar is named `xdna`.
|
||||
|
||||
## MemryX MX3
|
||||
|
||||
This detector is available for use with the MemryX MX3 accelerator M.2 module. Frigate supports the MX3 on compatible hardware platforms, providing efficient and high-performance object detection.
|
||||
|
||||
@@ -45,10 +45,10 @@ Any detection below `min_score` will be immediately thrown out and never tracked
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set score filters globally.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| **Object filters > Person > Minimum confidence** | Minimum score for a single detection to initiate tracking |
|
||||
| **Object filters > Person > Confidence threshold** | Minimum computed (median) score to be considered a true positive |
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ---------------------------------------------------------------- |
|
||||
| **Object filters > Person > Min Score** | Minimum score for a single detection to initiate tracking |
|
||||
| **Object filters > Person > Threshold** | Minimum computed (median) score to be considered a true positive |
|
||||
|
||||
To override score filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
|
||||
|
||||
@@ -103,12 +103,12 @@ Conceptually, a ratio of 1 is a square, 0.5 is a "tall skinny" box, and 2 is a "
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set shape filters globally.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
|
||||
|
||||
To override shape filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
|
||||
|
||||
|
||||
@@ -70,14 +70,14 @@ Object filters help reduce false positives by constraining the size, shape, and
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Minimum confidence** | Minimum score for the object to initiate tracking |
|
||||
| **Object filters > Person > Confidence threshold** | Minimum computed score to be considered a true positive |
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Min Score** | Minimum score for the object to initiate tracking |
|
||||
| **Object filters > Person > Threshold** | Minimum computed score to be considered a true positive |
|
||||
|
||||
To override filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||
|
||||
|
||||
@@ -191,12 +191,14 @@ cameras:
|
||||
detect:
|
||||
enabled: false
|
||||
record:
|
||||
enabled: true
|
||||
enabled: false
|
||||
profiles:
|
||||
away:
|
||||
enabled: true
|
||||
detect:
|
||||
enabled: true
|
||||
record:
|
||||
enabled: true
|
||||
home:
|
||||
enabled: false
|
||||
```
|
||||
@@ -249,12 +251,6 @@ Leaving the `objects` section empty (or omitting `track`) does not clear the lis
|
||||
|
||||
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
|
||||
|
||||
### Why can't a profile enable recording when it's disabled in the base config?
|
||||
|
||||
Frigate only sets up a camera's recording stream at startup when recording is enabled in the base config, so enabling it later from a profile has no effect. The same applies to turning recording on from the UI or MQTT.
|
||||
|
||||
To keep recording off by default, leave `record.enabled: true` in the base config and create a profile that sets `record.enabled: false`. Activate that profile and it will be restored automatically when Frigate starts.
|
||||
|
||||
### Can I schedule profiles to be enabled or disabled at certain times?
|
||||
|
||||
Not within Frigate itself. Frigate is an NVR, not an automation platform, so it intentionally does not include a scheduler for activating profiles. Instead, activate profiles from an automation platform that already handles time- and event-based triggers well, such as [Home Assistant](https://www.home-assistant.io/) or [Node-RED](https://nodered.org/). These integrate with Frigate and give you far more robust and flexible scheduling than a built-in scheduler could.
|
||||
|
||||
@@ -133,12 +133,13 @@ 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. See [Provider support](/configuration/genai/genai_config#provider-support) for which providers can serve this role.
|
||||
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).
|
||||
|
||||
To use a GenAI provider for semantic search:
|
||||
To use llama.cpp for semantic search:
|
||||
|
||||
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.
|
||||
1. Configure a GenAI provider with `embeddings` in its `roles`.
|
||||
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">
|
||||
@@ -173,6 +174,8 @@ 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.
|
||||
@@ -242,8 +245,8 @@ Triggers are best configured through the Frigate UI.
|
||||
1. Navigate to <NavPath path="Settings > Enrichments > Triggers" /> and select a camera from the dropdown menu.
|
||||
2. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one.
|
||||
3. In the **Create Trigger** wizard:
|
||||
- Enter a **Name** for the trigger (e.g., "Red Car Alert"). Frigate derives the trigger's
|
||||
internal **ID** from this name, which can be revealed and edited with the show/hide toggle.
|
||||
- Enter a **Name** for the trigger (e.g., "Red Car Alert").
|
||||
- Enter a descriptive **Friendly Name** for the trigger (e.g., "Red car on the driveway camera").
|
||||
- Select the **Type** (`Thumbnail` or `Description`).
|
||||
- For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold.
|
||||
- For `Description`, enter text to trigger this action when a similar tracked object description is detected.
|
||||
|
||||
@@ -28,7 +28,7 @@ During testing, enable the Zones option for the [Debug view](/usage/live#the-sin
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Under the **Zones** section, click the plus icon to add a new zone.
|
||||
3. Click on the camera's latest image to create the points for the zone boundary. Click the first point again to close the polygon.
|
||||
4. Configure zone options such as **Name**, **Objects**, **Loitering Time**, and **Inertia** in the zone editor.
|
||||
4. Configure zone options such as **Friendly name**, **Objects**, **Loitering time**, and **Inertia** in the zone editor.
|
||||
5. Press **Save** when finished.
|
||||
|
||||
</TabItem>
|
||||
@@ -200,7 +200,7 @@ When using loitering zones, a review item will behave in the following way:
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Edit or create the zone (e.g., `sidewalk`).
|
||||
- Set **Loitering Time** to the desired number of seconds (e.g., `4`)
|
||||
- Set **Loitering time** to the desired number of seconds (e.g., `4`)
|
||||
- Under **Objects**, add the relevant object types (e.g., `person`)
|
||||
|
||||
</TabItem>
|
||||
@@ -291,7 +291,7 @@ Accurate real-world distance measurements are required to estimate speeds. These
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Create or edit a zone with exactly 4 points aligned to the ground plane.
|
||||
3. In the zone editor, enable **Speed Estimation** and enter the real-world **Line A distance**, **Line B distance**, **Line C distance**, and **Line D distance** between each pair of consecutive points.
|
||||
3. In the zone editor, enter the real-world **Distances** between each pair of consecutive points.
|
||||
- For example, if the distance between the first and second points is 10 meters, between the second and third is 12 meters, etc.
|
||||
4. Distances are measured in meters (metric) or feet (imperial), depending on the **Unit system** setting.
|
||||
|
||||
@@ -358,7 +358,7 @@ Zones can be configured with a minimum speed requirement, meaning an object must
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Edit or create the zone with distances configured.
|
||||
- Set **Speed Threshold** to the desired minimum speed (e.g., `20`)
|
||||
- Set **Speed threshold** to the desired minimum speed (e.g., `20`)
|
||||
- The unit is kph or mph, depending on the **Unit system** setting
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -54,7 +54,7 @@ An object filter mask drops any [bounding box](#bounding-box) whose bottom cente
|
||||
|
||||
## Min Score
|
||||
|
||||
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded. Set with `min_score` in the config, shown as **Minimum confidence** in the settings UI.
|
||||
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded.
|
||||
|
||||
## Model
|
||||
|
||||
@@ -86,7 +86,7 @@ A more specific identity assigned to a [tracked object](#tracked-object-event-in
|
||||
|
||||
## Threshold
|
||||
|
||||
The median score an object must reach to be considered a true positive. Set with `threshold` in the config, shown as **Confidence threshold** in the settings UI.
|
||||
The median score an object must reach to be considered a true positive.
|
||||
|
||||
## Top Score
|
||||
|
||||
|
||||
@@ -72,19 +72,12 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
**AMD**
|
||||
|
||||
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD discrete and recent integrated GPUs to provide efficient object detection
|
||||
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection
|
||||
- [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector)
|
||||
- Runs best on discrete AMD GPUs
|
||||
- <CommunityBadge /> [XDNA2 (Ryzen AI)](#amd-xdna2): AMD XDNA2 NPU (sub-watt power AI/ML processor separate to the GPU) inside Strix and other "AI" branded AMD platforms
|
||||
- Has only been tested with YOLOv9, in theory other graphs may be compiled too.
|
||||
- Runs via ZMQ proxy which adds some latency, only recommended for local connection
|
||||
|
||||
**Apple Silicon**
|
||||
|
||||
- [ONNX via lighter](#apple-silicon): The ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs in the lighter container runtime
|
||||
- [Supports the same model architectures as the ONNX detector](../../configuration/object_detectors#apple-neural-engine-lighter)
|
||||
- Runs inside the Frigate container, with no separate detector process to set up
|
||||
- The recommended way to run Frigate on a Mac
|
||||
- [Apple Silicon](#apple-silicon): Apple Silicon is usable on all M1 and newer Apple Silicon devices to provide efficient and fast object detection
|
||||
- [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#apple-silicon-detector)
|
||||
- Runs well with any size models including large
|
||||
@@ -189,7 +182,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`. 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -218,13 +211,7 @@ Inference is done with the `onnx` detector type. Speeds will vary greatly depend
|
||||
|
||||
### Apple Silicon
|
||||
|
||||
Frigate on a Mac is best run in the [lighter](https://github.com/fieldwork-ai/lighter) container runtime, where the [ONNX detector](../configuration/object_detectors.md#apple-neural-engine-lighter) runs on the Neural Engine of M1 and newer Macs from inside the Frigate container. There is no separate detector process to install or keep running, and the same container can decode video on the Mac's media engine.
|
||||
|
||||
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
|
||||
| ---- | -------------------------------------- | ----------------------- | ---------------------- |
|
||||
| M1 | t-320: 3.3 ms s-320: 7 ms s-640: 13 ms | 320: 6.6 ms | Nano-320: 38 ms |
|
||||
|
||||
Alternatively, with the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon.
|
||||
With the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon.
|
||||
|
||||
:::warning
|
||||
|
||||
@@ -240,7 +227,7 @@ Apple Silicon can not run within a container, so a ZMQ proxy is utilized to comm
|
||||
|
||||
### ROCm - AMD GPU
|
||||
|
||||
With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many AMD discrete and integrated GPUs, see the [supported GPUs](../configuration/object_detectors.md#supported-gpus-rocm).
|
||||
With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs.
|
||||
|
||||
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
|
||||
| -------------- | --------------------------- | ------------------------- | ---------------------- |
|
||||
@@ -341,32 +328,6 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo
|
||||
| ---------------- | ----------------------------------- |
|
||||
| yolov9-tiny | ~ 4 ms |
|
||||
|
||||
### AMD Ryzen AI / XDNA2
|
||||
|
||||
Frigate supports AMD XDNA2 NPUs through the community-maintained
|
||||
frigate-xdna ZMQ sidecar. It works with stock Frigate and supports
|
||||
Frigate+ models or compatible local YOLO ONNX models. Models are compiled
|
||||
once on the target system and cached for subsequent use.
|
||||
|
||||
Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2
|
||||
devices are not yet qualified; XDNA1 is unsupported.
|
||||
|
||||
Measured YOLOv9 detector latency on Strix Halo:
|
||||
|
||||
| Model | 320 | 640 |
|
||||
| ----- | ---: | ---: |
|
||||
| YOLOv9-T | ~7.4 ms | unsupported |
|
||||
| YOLOv9-S | ~9.0 ms | ~20.0 ms |
|
||||
| YOLOv9-M | ~13.1 ms | ~34.4 ms |
|
||||
| YOLOv9-C | ~14.1 ms | ~35.2 ms |
|
||||
| YOLOv9-E | ~69.4 ms | ~224.8 ms |
|
||||
|
||||
**YOLOv9-C at 320 is the recommended quality/performance balance.**
|
||||
C at 640 is also usable where the lower throughput is acceptable.
|
||||
|
||||
Setup, model preparation, and compatibility details are available
|
||||
[in the frigate-xdna documentation](https://github.com/mitchins/frigate-xdna).
|
||||
|
||||
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
|
||||
|
||||
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
|
||||
|
||||
@@ -78,9 +78,9 @@ Users of the Snapcraft build of Docker cannot use storage locations outside your
|
||||
|
||||
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
|
||||
|
||||
Frigate needs about **210MB** for **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose). If raising the shm size does not help, check your [process and file limits](#process-and-file-limits) as well.
|
||||
The default shm size of **128MB** is fine for setups with **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose). If raising the shm size does not help, check your [process and file limits](#process-and-file-limits) as well.
|
||||
|
||||
The Frigate container also stores logs and an nginx cache in shm, which can take up to **50MB**. The calculator below includes this, along with room for 2 cameras added while Frigate is running. Its result holds about 25 frames per camera, a little above the 20 frame minimum Frigate warns below. Frigate keeps up to 50 when there is room, which gives slow processes more time to read each frame.
|
||||
The Frigate container also stores logs in shm, which can take up to **40MB**, so make sure to take this into account in your math as well.
|
||||
|
||||
<ShmCalculator/>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ title: Updating
|
||||
|
||||
# Updating Frigate
|
||||
|
||||
The current stable version of Frigate is **0.18.2**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.18.2).
|
||||
The current stable version of Frigate is **0.18.0**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.18.0).
|
||||
|
||||
Keeping Frigate up to date ensures you benefit from the latest features, performance improvements, and bug fixes. The update process varies slightly depending on your installation method (Docker, Home Assistant App, etc.). Below are instructions for the most common setups.
|
||||
|
||||
@@ -31,21 +31,21 @@ If you’re running Frigate via Docker (recommended method), follow these steps:
|
||||
|
||||
2. **Update and Pull the Latest Image**:
|
||||
- If using Docker Compose:
|
||||
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.18.2` instead of `0.17.1`). For example:
|
||||
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.18.0` instead of `0.17.1`). For example:
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
image: ghcr.io/blakeblackshear/frigate:0.18.2
|
||||
image: ghcr.io/blakeblackshear/frigate:0.18.0
|
||||
```
|
||||
- Then pull the image:
|
||||
```bash
|
||||
docker pull ghcr.io/blakeblackshear/frigate:0.18.2
|
||||
docker pull ghcr.io/blakeblackshear/frigate:0.18.0
|
||||
```
|
||||
- **Note for `stable` Tag Users**: If your `docker-compose.yml` uses the `stable` tag (e.g., `ghcr.io/blakeblackshear/frigate:stable`), you don’t need to update the tag manually. The `stable` tag always points to the latest stable release after pulling.
|
||||
- If using `docker run`:
|
||||
- Pull the image with the appropriate tag (e.g., `0.18.2`, `0.18.2-tensorrt`, or `stable`):
|
||||
- Pull the image with the appropriate tag (e.g., `0.18.0`, `0.18.0-tensorrt`, or `stable`):
|
||||
```bash
|
||||
docker pull ghcr.io/blakeblackshear/frigate:0.18.2
|
||||
docker pull ghcr.io/blakeblackshear/frigate:0.18.0
|
||||
```
|
||||
|
||||
3. **Start the Container**:
|
||||
@@ -100,7 +100,7 @@ If an update causes issues:
|
||||
1. Stop Frigate.
|
||||
2. Restore your backed-up config file and database.
|
||||
3. Revert to the previous image version:
|
||||
- For Docker: Specify an older tag (e.g., `ghcr.io/blakeblackshear/frigate:0.18.1`) in your `docker run` command.
|
||||
- For Docker: Specify an older tag (e.g., `ghcr.io/blakeblackshear/frigate:0.17.1`) in your `docker run` command.
|
||||
- For Docker Compose: Edit your `docker-compose.yml`, specify the older version tag (e.g., `ghcr.io/blakeblackshear/frigate:0.16.4`), and re-run `docker compose up -d`.
|
||||
- For Home Assistant: Restore from the app/addon backup you took before you updated.
|
||||
4. Verify the old version is running again.
|
||||
|
||||
@@ -45,8 +45,6 @@ There are many solutions available to implement reverse proxies and the communit
|
||||
- [Nginx](#nginx-reverse-proxy)
|
||||
- [Traefik](#traefik-reverse-proxy)
|
||||
- [Caddy](#caddy-reverse-proxy)
|
||||
- [HAProxy](#haproxy)
|
||||
- [OPNSense HAProxy Add-on](#opnsense-haproxy-add-on)
|
||||
|
||||
## Apache2 Reverse Proxy
|
||||
|
||||
@@ -220,68 +218,3 @@ frigate.YOUR_DOMAIN.TLD {
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## HAProxy
|
||||
|
||||
This example is for a pure haproxy installation on a dedicated host. It assumes you have already combined the certificate parts in de cert file.
|
||||
|
||||
The frontend config:
|
||||
```# Public Service: SSLSecured (Frigate)
|
||||
frontend SSLSecured
|
||||
bind XXX.XXX.XXX.XXX:443 ssl crt /etc/haproxy/certs/frigate.cert
|
||||
mode http
|
||||
option http-keep-alive
|
||||
default_backend NVR
|
||||
|
||||
# CONDITION: NVR
|
||||
acl acl_frigate hdr(host) -i nvr.example.video
|
||||
|
||||
# RULE: NVR
|
||||
use_backend NVR if acl_frigate
|
||||
```
|
||||
|
||||
The backend config:
|
||||
```# Backend Pool: NVR (Frigate)
|
||||
backend NVR
|
||||
mode http
|
||||
balance source
|
||||
# stickiness
|
||||
stick-table type ipv4 size 50k expire 30m
|
||||
stick on src
|
||||
http-reuse safe
|
||||
server Frigate XXX.XXX.XXX.XXX:8971
|
||||
```
|
||||
The above config is created for a Layer 7 proxy (application layer, in this case http), if streaming the camera feeds are a little spotty or somewhat unstable, you can try to change the *mode* from http to tcp in both the frontend and backend config, which sets up a Layer 4 proxy (proxy by protocol). Make sure you change hdr(host) to req.ssl_sni in the frontend Condition to make the host based condition work on tcp traffic.
|
||||
|
||||
## OPNSense HAProxy add-on
|
||||
|
||||
This guide is for the OPNSense firewall add-on *os-haproxy* and is configured and tested on version 5.1 of the add-on on OPNSense 26.7.
|
||||
The guide starts where you are already done setting up the system, *have a certificate added to the certificate store* and *installed the plugin/add-on*.
|
||||
|
||||
Step by step plan:
|
||||
1. Go to Services -> HAProxy, click on Settings;
|
||||
2. Add a Real Server under *Real Servers*;
|
||||
3. Fill in the fields *Name or Prefix* and *Description*;
|
||||
4. set *Type* to **static**;
|
||||
5. fill in the frigate IP in the *FQDN or IP* field;
|
||||
6. under *Common Options* fill *Port* with the port number for your Frigate (e.g. 8971) and
|
||||
7. Click **Save** to store this configuration.
|
||||
8. Under *Virtual Services* open *Backend Pools*;
|
||||
9. Add a *Backend Pool*;
|
||||
10. Fill in the fields *Name or Prefix* and *Description* but use something else then what you used under *Real Servers* otherwise if and when rules and conditions are needed it gets really confusing really fast, the use of them is out of the scope of this guide for a basic functioning haproxy add-on;
|
||||
11. set *Mode* to **HTTP (Layer 7)**;
|
||||
12. set *Balancing Algorithm* to **Source-IP Hash**;
|
||||
13. set *Servers* by choosing the *Real Servers* instance you added in step 2->6;
|
||||
14. Click **Save** to store this configuration.
|
||||
15. Under *Virtual Services* open *Public Services*;
|
||||
16. Add a *Public Service*;
|
||||
17. Fill in the fields *Name or Prefix* and *Description* but use something else then what you used under *Real Servers* otherwise if and when rules and conditions are needed it gets really confusing really fast, the use of them is out of the scope of this guide for a basic functioning haproxy add-on;
|
||||
18. Fill in the external address the haproxy instance will be listening on in *Listen Addresses*;
|
||||
19. set *Type* to **HTTP/HTTPS (SSL offloading)**
|
||||
20. set *Default Backend Pool* to the option carrying *the name you used in step 9*;
|
||||
21. check *Enable SSL Offloading*;
|
||||
22. Under *SSL Offloading* you set *Default certificate* to the certificate you added before you started following this guide;
|
||||
23. Click **Save** to store this configuration.
|
||||
24. Click **Apply** to reload the haproxy config on the service running.
|
||||
|
||||
**NOTE: This will forward _ALL_ requests on port 443 to your frigate instance.** If that is not what you want or have more then one website alive behind the haproxy you will need to create a *Condition* to trigger when the right URL is used and a *Rules* rule to use the right backend under *Rules & Checks* when the condition is met. This is a useful hint for those who need it, though it is out of the scope of this guide.
|
||||
|
||||
@@ -11,12 +11,6 @@ MQTT requires a network connection to your broker. This is typically local, but
|
||||
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
Wherever a topic below includes a camera, mask, or zone name, use its `ID` from the config, not its `friendly_name`. For example, a camera with `friendly_name: "Back Yard"` and ID `back_yard` publishes to `frigate/back_yard/...`, not `frigate/Back Yard/...`.
|
||||
|
||||
:::
|
||||
|
||||
## General Frigate Topics
|
||||
|
||||
### `frigate/available`
|
||||
@@ -218,7 +212,6 @@ An `update` with the same ID will be published when:
|
||||
- The severity changes from `detection` to `alert`
|
||||
- Additional objects are detected
|
||||
- An object is recognized via face, lpr, etc.
|
||||
- A [state classification](/configuration/custom_classification/state_classification#review-items) model changes state
|
||||
|
||||
When the review activity has ended a final `end` message is published.
|
||||
|
||||
@@ -242,8 +235,7 @@ When the review activity has ended a final `end` message is published.
|
||||
"objects": ["person", "car"],
|
||||
"sub_labels": [],
|
||||
"zones": [],
|
||||
"audio": [],
|
||||
"classification_state_changes": []
|
||||
"audio": []
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
@@ -262,16 +254,7 @@ When the review activity has ended a final `end` message is published.
|
||||
"objects": ["person", "car"],
|
||||
"sub_labels": ["Bob"],
|
||||
"zones": ["front_yard"],
|
||||
"audio": [],
|
||||
"classification_state_changes": [
|
||||
// verified changes of state classification models on this camera
|
||||
{
|
||||
"model": "front_gate",
|
||||
"from": "closed",
|
||||
"to": "open",
|
||||
"timestamp": 1718987131.52
|
||||
}
|
||||
]
|
||||
"audio": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ The [Advanced Camera Card](https://card.camera/#/README) is a Home Assistant das
|
||||
It supports automatically setting the sub labels in Frigate for person objects that are detected and recognized.
|
||||
This is a fork (with fixed errors and new features) of [original Double Take](https://github.com/jakowenko/double-take) project which, unfortunately, isn't being maintained by author.
|
||||
|
||||
## [frigate-abr](https://github.com/007hacky007/frigate-abr)
|
||||
|
||||
[frigate-abr](https://github.com/007hacky007/frigate-abr) is a drop-in Docker image of Frigate that adds adaptive bitrate (ABR) playback for recordings: a sidecar transcodes footage to lower quality tiers on demand, for reviewing over slow remote connections. Segments are transcoded when played and cached, so no additional stream is recorded. Frigate itself is not modified.
|
||||
|
||||
## [Frigate Notify](https://github.com/0x2142/frigate-notify)
|
||||
|
||||
[Frigate Notify](https://github.com/0x2142/frigate-notify) is a simple app designed to send notifications from Frigate to your favorite platforms. Intended to be used with standalone Frigate installations - Home Assistant not required, MQTT is optional but recommended.
|
||||
|
||||
@@ -21,13 +21,7 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
|
||||
|
||||
### Can I keep using my Frigate+ models even if I do not renew my subscription?
|
||||
|
||||
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models you train during an active subscription remain licensed for your continued use even after your subscription ends — models already in your model cache will keep working indefinitely. An active subscription is required to train new models and download new versions.
|
||||
|
||||
### Can I use Frigate+ models commercially?
|
||||
|
||||
A standard subscription covers use on camera systems you own or operate, including for your business. A shop, restaurant, warehouse, or office running Frigate+ at its own locations (including multiple locations) is exactly the kind of use the subscription is for.
|
||||
What the standard subscription does not cover is using Frigate+ models to provide a product or service to others. If you're deploying models at your customers' sites, bundling them with hardware you sell, or running them as part of a hosted or managed service, even if your customers never receive the model files themselves, you'll need a commercial license.
|
||||
Note that professional installers are fine under standard subscriptions when each customer holds their own Frigate+ subscription. The commercial license is for cases where your license powers your customers' sites.
|
||||
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models.
|
||||
|
||||
### Why can't I submit images to Frigate+?
|
||||
|
||||
|
||||
@@ -63,20 +63,20 @@ Frigate+ models generally have much higher scores than the default model provide
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Minimum confidence** and **Confidence threshold** for each object type, then click **Save**.
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Min Score** and **Threshold** for each object type, then click **Save**.
|
||||
|
||||
| Object | Minimum confidence | Confidence threshold |
|
||||
| ----------------- | ------------------ | -------------------- |
|
||||
| **dog** | .7 | .9 |
|
||||
| **cat** | .65 | .8 |
|
||||
| **face** | .7 | |
|
||||
| **package** | .65 | .9 |
|
||||
| **license_plate** | .6 | |
|
||||
| **amazon** | .75 | |
|
||||
| **ups** | .75 | |
|
||||
| **fedex** | .75 | |
|
||||
| **person** | .65 | .85 |
|
||||
| **car** | .65 | .85 |
|
||||
| Object | Min Score | Threshold |
|
||||
| ----------------- | --------- | --------- |
|
||||
| **dog** | .7 | .9 |
|
||||
| **cat** | .65 | .8 |
|
||||
| **face** | .7 | |
|
||||
| **package** | .65 | .9 |
|
||||
| **license_plate** | .6 | |
|
||||
| **amazon** | .75 | |
|
||||
| **ups** | .75 | |
|
||||
| **fedex** | .75 | |
|
||||
| **person** | .65 | .85 |
|
||||
| **car** | .65 | .85 |
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -65,11 +65,11 @@ Some users may find that Frigate+ models result in more false positives initiall
|
||||
|
||||
Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported:
|
||||
|
||||
- **People**: `person`, `face`, `baby`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate`
|
||||
- **People**: `person`, `face`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `license_plate`
|
||||
- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail`
|
||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`, `possum`, `rodent`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller`
|
||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`
|
||||
|
||||
Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
|
||||
|
||||
@@ -77,12 +77,9 @@ Other object types available in the default Frigate model are not available. Add
|
||||
|
||||
Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings.
|
||||
|
||||
Where possible, these labels are mapped to existing labels during training. For example, any `duck` labels are mapped to `bird` until support for new labels is added.
|
||||
Where possible, these labels are mapped to existing labels during training. For example, any `baby` labels are mapped to `person` until support for new labels is added.
|
||||
|
||||
- **Vehicles**: `tractor`, `golf_cart`, `bus`, `airplane`, `helicopter`, `rickshaw`, `scooter`
|
||||
- **Delivery Logos**: `bpost`, `auspost`, `aramex`, `transoflex`, `parcelforce`, `hermes`, `cargus`, `fan_courier`, `sameday`, `la_poste`
|
||||
- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet`
|
||||
- **Other**: `sports_ball`, `drone`, `lawnmower`
|
||||
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday`
|
||||
|
||||
Candidate labels are not available for automatic suggestions.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -192,6 +184,6 @@ Filters and masks only hide the incorrect result - they don't teach Frigate what
|
||||
|
||||
### Where do I see problems Frigate has detected?
|
||||
|
||||
Open System > Health. The Notices list keeps a record of problems Frigate has found. Acknowledge an entry to hide it until the problem happens again, or mute it to hide it for good. Hidden entries stay listed under Show hidden in the filter. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has entries showing. On mobile, tap the warning icon in the bottom navigation bar to see them.
|
||||
Open System > Health. The Notices list keeps a record of problems Frigate has found, and you can dismiss any entry to acknowledge it. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has undismissed entries. On mobile, tap the warning icon in the bottom navigation bar to see them.
|
||||
|
||||
The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports.
|
||||
|
||||
@@ -397,11 +397,19 @@ dmesg | grep -i -E "gpu|drm|reset|hang"
|
||||
|
||||
Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date.
|
||||
|
||||
#### Step 6: Verify go2rtc stream configuration
|
||||
#### Step 6: Verify hardware acceleration configuration
|
||||
|
||||
An incorrect `hwaccel_args` preset can cause ffmpeg to fail silently or consume excessive CPU, starving the detector of resources.
|
||||
|
||||
- After upgrading Frigate, verify your preset matches your hardware (e.g., `preset-intel-qsv-h264` instead of the deprecated `preset-vaapi`).
|
||||
- For h265 cameras, use the corresponding h265 preset (e.g., `preset-intel-qsv-h265`).
|
||||
- Note that `hwaccel_args` are only relevant for the detect stream. Frigate does not decode the record stream.
|
||||
|
||||
#### Step 7: Verify go2rtc stream configuration
|
||||
|
||||
Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely.
|
||||
|
||||
#### Step 7: Check system resources
|
||||
#### Step 8: Check system resources
|
||||
|
||||
If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host:
|
||||
|
||||
|
||||
+22
-30
@@ -3,9 +3,6 @@ import * as path from "node:path";
|
||||
import type { Config, PluginConfig } from "@docusaurus/types";
|
||||
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
||||
|
||||
// Bump when a new stable release ships
|
||||
const STABLE_VERSION = "0.18";
|
||||
|
||||
const config: Config = {
|
||||
title: "Frigate",
|
||||
tagline: "NVR With Realtime Object Detection for IP Cameras",
|
||||
@@ -26,17 +23,17 @@ const config: Config = {
|
||||
mermaid: true,
|
||||
},
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
localeConfigs: {
|
||||
en: {
|
||||
label: "English",
|
||||
},
|
||||
label: 'English',
|
||||
}
|
||||
},
|
||||
},
|
||||
themeConfig: {
|
||||
announcementBar: {
|
||||
id: "frigate_plus",
|
||||
id: 'frigate_plus',
|
||||
content: `
|
||||
<span style="margin-right: 8px; display: inline-block; animation: pulse 2s infinite;">🚀</span>
|
||||
Get more relevant and accurate detections with Frigate+ models.
|
||||
@@ -48,8 +45,8 @@ const config: Config = {
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
</style>`,
|
||||
backgroundColor: "#005f73",
|
||||
textColor: "#e0fbfc",
|
||||
backgroundColor: '#005f73',
|
||||
textColor: '#e0fbfc',
|
||||
isCloseable: false,
|
||||
},
|
||||
docs: {
|
||||
@@ -86,15 +83,15 @@ const config: Config = {
|
||||
},
|
||||
},
|
||||
prism: {
|
||||
magicComments: [
|
||||
magicComments:[
|
||||
{
|
||||
className: "theme-code-block-highlighted-line",
|
||||
line: "highlight-next-line",
|
||||
block: { start: "highlight-start", end: "highlight-end" },
|
||||
className: 'theme-code-block-highlighted-line',
|
||||
line: 'highlight-next-line',
|
||||
block: {start: 'highlight-start', end: 'highlight-end'},
|
||||
},
|
||||
{
|
||||
className: "code-block-error-line",
|
||||
line: "highlight-error-line",
|
||||
className: 'code-block-error-line',
|
||||
line: 'highlight-error-line',
|
||||
},
|
||||
],
|
||||
additionalLanguages: ["bash", "json"],
|
||||
@@ -134,11 +131,6 @@ const config: Config = {
|
||||
srcDark: "img/branding/logo-dark.svg",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
href: "https://github.com/blakeblackshear/frigate/releases",
|
||||
label: `${STABLE_VERSION}`,
|
||||
position: "left",
|
||||
},
|
||||
{
|
||||
to: "/",
|
||||
activeBasePath: "docs",
|
||||
@@ -156,19 +148,19 @@ const config: Config = {
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
type: "localeDropdown",
|
||||
position: "right",
|
||||
type: 'localeDropdown',
|
||||
position: 'right',
|
||||
dropdownItemsAfter: [
|
||||
{
|
||||
label: "简体中文(社区翻译)",
|
||||
href: "https://docs.frigate-cn.video",
|
||||
},
|
||||
],
|
||||
label: '简体中文(社区翻译)',
|
||||
href: 'https://docs.frigate-cn.video',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
href: "https://github.com/blakeblackshear/frigate",
|
||||
label: "GitHub",
|
||||
position: "right",
|
||||
href: 'https://github.com/blakeblackshear/frigate',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Generated
+25
-34
@@ -7030,9 +7030,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -7698,15 +7698,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
|
||||
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||
"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",
|
||||
@@ -7714,10 +7713,6 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/bytes": {
|
||||
@@ -9465,9 +9460,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.16",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
|
||||
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -10130,9 +10125,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -11380,15 +11375,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.4.tgz",
|
||||
"integrity": "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
|
||||
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
@@ -17453,9 +17448,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
@@ -17463,10 +17458,6 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr/node_modules/ipaddr.js": {
|
||||
@@ -19238,9 +19229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
|
||||
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
|
||||
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -19522,9 +19513,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
||||
@@ -2,53 +2,42 @@ import React, { useState, useEffect } from "react";
|
||||
import Admonition from "@theme/Admonition";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
// follows calculate_shm_requirements() in frigate/util/services.py, but sizes
|
||||
// for 25 frames so float rounding can't land under its 20 frame warning
|
||||
const MB = 1048576;
|
||||
const RESERVED_MB = 50; // logs + nginx cache
|
||||
const TARGET_FRAMES = 25;
|
||||
const DEFAULT_FRAMES = 50;
|
||||
|
||||
const frameMb = (width, height) =>
|
||||
Math.round(((width * height * 1.5 + 270480) / MB) * 10) / 10;
|
||||
|
||||
// room for 2 cameras added at runtime, sized as 720p
|
||||
const addedCamerasMb = 2 * frameMb(1280, 720);
|
||||
|
||||
const ShmCalculator = () => {
|
||||
const [width, setWidth] = useState(1280);
|
||||
const [height, setHeight] = useState(720);
|
||||
const [cameraCount, setCameraCount] = useState(1);
|
||||
const [result, setResult] = useState("-");
|
||||
const [singleCameraShm, setSingleCameraShm] = useState("-");
|
||||
const [addedCamerasShm, setAddedCamerasShm] = useState("-");
|
||||
const [fullRingShm, setFullRingShm] = useState("-");
|
||||
const [result, setResult] = useState("26.32MB");
|
||||
const [singleCameraShm, setSingleCameraShm] = useState("26.32MB");
|
||||
const [totalShm, setTotalShm] = useState("26.32MB");
|
||||
|
||||
const calculate = () => {
|
||||
if (!width || !height || !cameraCount) {
|
||||
setResult("Please enter valid values");
|
||||
setSingleCameraShm("-");
|
||||
setAddedCamerasShm("-");
|
||||
setFullRingShm("-");
|
||||
setTotalShm("-");
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraFramesMb = cameraCount * frameMb(width, height) + addedCamerasMb;
|
||||
// Single camera base SHM calculation (excluding logs)
|
||||
// Formula: (width * height * 1.5 * 20 + 270480) / 1048576
|
||||
const singleCameraBase =
|
||||
(width * height * 1.5 * 20 + 270480) / 1048576;
|
||||
setSingleCameraShm(`${singleCameraBase.toFixed(2)}mb`);
|
||||
|
||||
setSingleCameraShm(formatMb(frameMb(width, height) * TARGET_FRAMES));
|
||||
setAddedCamerasShm(formatMb(addedCamerasMb * TARGET_FRAMES));
|
||||
setResult(formatMb(Math.round(RESERVED_MB + cameraFramesMb * TARGET_FRAMES)));
|
||||
setFullRingShm(
|
||||
formatMb(Math.round(RESERVED_MB + cameraFramesMb * DEFAULT_FRAMES)),
|
||||
);
|
||||
};
|
||||
// Total SHM calculation (multiple cameras, including logs)
|
||||
const totalBase = singleCameraBase * cameraCount;
|
||||
const finalResult = totalBase + 40; // Default includes logs +40mb
|
||||
|
||||
const formatMb = (value) => {
|
||||
if (value >= 1024) {
|
||||
return `${(value / 1024).toFixed(2)}gb`;
|
||||
setTotalShm(`${(totalBase + 40).toFixed(2)}mb`);
|
||||
|
||||
// Format result
|
||||
if (finalResult < 1) {
|
||||
setResult(`${(finalResult * 1024).toFixed(2)}kb`);
|
||||
} else if (finalResult >= 1024) {
|
||||
setResult(`${(finalResult / 1024).toFixed(2)}gb`);
|
||||
} else {
|
||||
setResult(`${finalResult.toFixed(2)}mb`);
|
||||
}
|
||||
|
||||
return `${Number(value.toFixed(2))}mb`;
|
||||
};
|
||||
|
||||
const formatWithUnit = (value) => {
|
||||
@@ -158,27 +147,19 @@ const ShmCalculator = () => {
|
||||
</div>
|
||||
<div className={styles.formulaDisplay}>
|
||||
<p>
|
||||
<strong>Per Camera:</strong> {formatWithUnit(singleCameraShm)}
|
||||
<strong>Single Camera:</strong> {formatWithUnit(singleCameraShm)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Formula:</strong> (width × height × 1.5 + 270480) ÷
|
||||
1048576 × {TARGET_FRAMES} frames
|
||||
<strong>Formula:</strong> (width × height × 1.5 × 20 + 270480) ÷
|
||||
1048576
|
||||
</p>
|
||||
{cameraCount > 1 && (
|
||||
<p>
|
||||
<strong>Total ({cameraCount} cameras):</strong> {formatWithUnit(totalShm)}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<strong>Room for 2 Added Cameras:</strong>{" "}
|
||||
{formatWithUnit(addedCamerasShm)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Logs and Cache:</strong> + {RESERVED_MB}
|
||||
<span className={styles.unit}>mb</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Birdseye Restream:</strong> + 8
|
||||
<span className={styles.unit}>mb</span> if enabled
|
||||
</p>
|
||||
<p>
|
||||
<strong>For {DEFAULT_FRAMES} Frames:</strong>{" "}
|
||||
{formatWithUnit(fullRingShm)}
|
||||
<strong>With Logs:</strong> + 40<span className={styles.unit}>mb</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Vendored
+28
-178
@@ -412,39 +412,6 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/go2rtc/streams/{stream_name}/bitrate:
|
||||
get:
|
||||
tags:
|
||||
- Camera
|
||||
summary: Go2Rtc Stream Bitrate
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Measure a go2rtc stream's bitrate over a few seconds.
|
||||
operationId:
|
||||
go2rtc_stream_bitrate_go2rtc_streams__stream_name__bitrate_get
|
||||
parameters:
|
||||
- name: stream_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Stream Name
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/ffprobe:
|
||||
get:
|
||||
tags:
|
||||
@@ -1088,7 +1055,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1132,7 +1098,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1287,7 +1252,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1647,7 +1611,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1695,7 +1658,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1743,7 +1705,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1785,7 +1746,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1869,7 +1829,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -2131,7 +2090,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -3266,7 +3225,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -3491,7 +3450,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -4216,20 +4174,19 @@ paths:
|
||||
Get notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_hidden: Also return acknowledged and muted notices, for the
|
||||
hidden list
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
|
||||
Returns:
|
||||
The notices
|
||||
operationId: get_notices_notices_get
|
||||
parameters:
|
||||
- name: include_hidden
|
||||
- name: include_dismissed
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
title: Include Hidden
|
||||
title: Include Dismissed
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -4264,16 +4221,16 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/muted_checks:
|
||||
/notices/dismissed_checks:
|
||||
get:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Get Muted Checks
|
||||
summary: Get Dismissed Checks
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get the muted config and stream check rows, newest first.
|
||||
operationId: get_muted_checks_notices_muted_checks_get
|
||||
Get the dismissed config and stream check rows, newest first.
|
||||
operationId: get_dismissed_checks_notices_dismissed_checks_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -4283,16 +4240,16 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/hidden:
|
||||
/notices/dismissed:
|
||||
delete:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Unhide All Notices
|
||||
summary: Purge Dismissed
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Show every acknowledged and muted notice and check row again.
|
||||
operationId: unhide_all_notices_notices_hidden_delete
|
||||
Delete every dismissed notice and check row so each can show again.
|
||||
operationId: purge_dismissed_notices_dismissed_delete
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -4302,83 +4259,18 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/{notice_id}/acknowledge:
|
||||
/notices/{notice_id}/dismiss:
|
||||
post:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Acknowledge Notice
|
||||
summary: Dismiss Notice
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Hide a notice until it happens again.
|
||||
Hide a notice or a config or stream check row.
|
||||
|
||||
Config and stream check rows and the update notice never repeat, so they
|
||||
can only be muted.
|
||||
operationId: acknowledge_notice_notices__notice_id__acknowledge_post
|
||||
parameters:
|
||||
- name: notice_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Notice Id
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/{notice_id}/mute:
|
||||
post:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Mute Notice
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Hide a notice or a config or stream check row for good.
|
||||
operationId: mute_notice_notices__notice_id__mute_post
|
||||
parameters:
|
||||
- name: notice_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Notice Id
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/{notice_id}/hidden:
|
||||
delete:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Unhide Notice
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Show an acknowledged or muted notice or check row again.
|
||||
operationId: unhide_notice_notices__notice_id__hidden_delete
|
||||
It stays hidden if the same problem happens again.
|
||||
operationId: dismiss_notice_notices__notice_id__dismiss_post
|
||||
parameters:
|
||||
- name: notice_id
|
||||
in: path
|
||||
@@ -4502,16 +4394,6 @@ paths:
|
||||
- type: 'null'
|
||||
default: 100
|
||||
title: Limit
|
||||
- name: offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
- type: 'null'
|
||||
default: 0
|
||||
title: Offset
|
||||
- name: after
|
||||
in: query
|
||||
required: false
|
||||
@@ -4666,7 +4548,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -4817,16 +4699,6 @@ paths:
|
||||
- type: 'null'
|
||||
default: 50
|
||||
title: Limit
|
||||
- name: offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
- type: 'null'
|
||||
default: 0
|
||||
title: Offset
|
||||
- name: cameras
|
||||
in: query
|
||||
required: false
|
||||
@@ -4928,7 +4800,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
- name: min_score
|
||||
in: query
|
||||
@@ -5008,7 +4880,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
- name: has_clip
|
||||
in: query
|
||||
@@ -5661,7 +5533,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Response Create Trigger Embedding Trigger Embedding Post
|
||||
'422':
|
||||
description: Validation Error
|
||||
@@ -5710,7 +5581,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Response Update Trigger Embedding Trigger Embedding
|
||||
Camera Name Name Put
|
||||
'422':
|
||||
@@ -5753,7 +5623,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Response Delete Trigger Embedding Trigger Embedding
|
||||
Camera Name Name Delete
|
||||
'422':
|
||||
@@ -5790,7 +5659,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Response Get Triggers Status Triggers Status Camera Name
|
||||
Get
|
||||
'422':
|
||||
@@ -7367,7 +7235,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
- name: cameras
|
||||
in: query
|
||||
@@ -7417,7 +7285,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -7587,7 +7455,6 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Response No Recordings Recordings Unavailable Get
|
||||
'422':
|
||||
description: Validation Error
|
||||
@@ -7791,19 +7658,13 @@ components:
|
||||
title: Update Topic
|
||||
config_data:
|
||||
anyOf:
|
||||
- additionalProperties: true
|
||||
type: object
|
||||
- type: object
|
||||
- type: 'null'
|
||||
title: Config Data
|
||||
skip_save:
|
||||
type: boolean
|
||||
title: Skip Save
|
||||
default: false
|
||||
replace_paths:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
title: Replace Paths
|
||||
type: object
|
||||
title: AppConfigSetBody
|
||||
AppPostLoginBody:
|
||||
@@ -8032,7 +7893,7 @@ components:
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
contentMediaType: application/octet-stream
|
||||
format: binary
|
||||
title: File
|
||||
type: object
|
||||
required:
|
||||
@@ -8042,7 +7903,7 @@ components:
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
contentMediaType: application/octet-stream
|
||||
format: binary
|
||||
title: File
|
||||
type: object
|
||||
required:
|
||||
@@ -8140,7 +8001,6 @@ components:
|
||||
tool_calls:
|
||||
anyOf:
|
||||
- items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: array
|
||||
- type: 'null'
|
||||
@@ -8436,7 +8296,6 @@ components:
|
||||
- type: 'null'
|
||||
title: Model Type
|
||||
data:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
title: Data
|
||||
type: object
|
||||
@@ -8508,8 +8367,7 @@ components:
|
||||
default: true
|
||||
draw:
|
||||
anyOf:
|
||||
- additionalProperties: true
|
||||
type: object
|
||||
- type: object
|
||||
- type: 'null'
|
||||
title: Draw
|
||||
default: {}
|
||||
@@ -8752,8 +8610,7 @@ components:
|
||||
description: Error message for failed jobs
|
||||
results:
|
||||
anyOf:
|
||||
- additionalProperties: true
|
||||
type: object
|
||||
- type: object
|
||||
- type: 'null'
|
||||
title: Results
|
||||
description: Result metadata for completed jobs
|
||||
@@ -9003,7 +8860,6 @@ components:
|
||||
- type: 'null'
|
||||
title: Base Url
|
||||
provider_options:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
title: Provider Options
|
||||
type: object
|
||||
@@ -9544,7 +9400,6 @@ components:
|
||||
type: string
|
||||
title: Tool Name
|
||||
arguments:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
title: Arguments
|
||||
type: object
|
||||
@@ -9622,11 +9477,6 @@ components:
|
||||
type:
|
||||
type: string
|
||||
title: Error Type
|
||||
input:
|
||||
title: Input
|
||||
ctx:
|
||||
type: object
|
||||
title: Context
|
||||
type: object
|
||||
required:
|
||||
- loc
|
||||
|
||||
+1
-3
@@ -15,9 +15,7 @@ from frigate.util.config import find_config_file
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Fork this manager so the forkserver does not start until the first
|
||||
# worker process, after the config has applied environment_vars.
|
||||
manager = mp.get_context("fork").Manager()
|
||||
manager = mp.Manager()
|
||||
faulthandler.enable()
|
||||
|
||||
# Setup the logging thread
|
||||
|
||||
+38
-309
@@ -61,10 +61,8 @@ from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
deep_merge,
|
||||
flatten_config_data,
|
||||
is_masked_camera_path,
|
||||
load_labels,
|
||||
process_config_query_string,
|
||||
split_config_key_path,
|
||||
update_yaml_file_bulk,
|
||||
)
|
||||
from frigate.util.config import (
|
||||
@@ -72,16 +70,9 @@ from frigate.util.config import (
|
||||
find_config_file,
|
||||
redact_credential,
|
||||
)
|
||||
from frigate.util.env import substitute_frigate_vars
|
||||
from frigate.util.live_streams import (
|
||||
generated_transcode_streams,
|
||||
sync_transcode_streams,
|
||||
)
|
||||
from frigate.util.object_names import get_categorized_object_names
|
||||
from frigate.util.schema import get_config_schema
|
||||
from frigate.util.services import (
|
||||
Go2rtcStreamIssue,
|
||||
find_restricted_go2rtc_stream_changes,
|
||||
get_nvidia_driver_info,
|
||||
process_logs,
|
||||
restart_frigate,
|
||||
@@ -315,12 +306,9 @@ def config(request: Request):
|
||||
mode="json", warnings="none", exclude_none=True
|
||||
)
|
||||
|
||||
is_admin = request.headers.get("remote-role") == "admin"
|
||||
|
||||
# hide environment_vars and the notification email from non-admin users
|
||||
if not is_admin:
|
||||
# remove environment_vars for non-admin users
|
||||
if request.headers.get("remote-role") != "admin":
|
||||
config.pop("environment_vars", None)
|
||||
redact_credential(config["notifications"], "email")
|
||||
|
||||
# redact mqtt credentials
|
||||
redact_credential(config["mqtt"], "password")
|
||||
@@ -377,15 +365,7 @@ def config(request: Request):
|
||||
camera_name
|
||||
)
|
||||
if base_sections:
|
||||
# copy so redaction below can't alter the profile manager's cache
|
||||
camera_dict["base_config"] = copy.deepcopy(base_sections)
|
||||
|
||||
# cameras inherit the global notification email
|
||||
if not is_admin:
|
||||
redact_credential(camera_dict["notifications"], "email")
|
||||
redact_credential(
|
||||
camera_dict.get("base_config", {}).get("notifications", {}), "email"
|
||||
)
|
||||
camera_dict["base_config"] = base_sections
|
||||
|
||||
# remove go2rtc stream passwords
|
||||
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
|
||||
@@ -404,8 +384,6 @@ def config(request: Request):
|
||||
|
||||
config["go2rtc"]["streams"][stream_name] = cleaned
|
||||
|
||||
_redact_go2rtc_credentials(config["go2rtc"])
|
||||
|
||||
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
|
||||
|
||||
for index, model in enumerate(config_obj.models):
|
||||
@@ -415,11 +393,6 @@ def config(request: Request):
|
||||
model_dict["non_logo_attributes"] = model.non_logo_attributes
|
||||
model_dict["labelmap"] = model.merged_labelmap
|
||||
|
||||
# report the configured reference rather than the resolved cache path,
|
||||
# so saving the config back doesn't lose the Frigate+ model
|
||||
if model.plus_id:
|
||||
model_dict["path"] = f"plus://{model.plus_id}"
|
||||
|
||||
if not config["plus"]["enabled"]:
|
||||
continue
|
||||
|
||||
@@ -432,28 +405,6 @@ def config(request: Request):
|
||||
return JSONResponse(content=config)
|
||||
|
||||
|
||||
def _redact_go2rtc_credentials(go2rtc: dict[str, Any]) -> None:
|
||||
"""Redact go2rtc secrets outside of stream URLs, in place."""
|
||||
for section in ("rtsp", "api", "mqtt"):
|
||||
if isinstance(go2rtc.get(section), dict):
|
||||
redact_credential(go2rtc[section], "username")
|
||||
redact_credential(go2rtc[section], "password")
|
||||
|
||||
if isinstance(go2rtc.get("ngrok"), dict):
|
||||
redact_credential(go2rtc["ngrok"], "command")
|
||||
|
||||
if isinstance(go2rtc.get("homekit"), dict):
|
||||
for accessory in go2rtc["homekit"].values():
|
||||
if isinstance(accessory, dict):
|
||||
for key in ("pin", "device_private", "pairings"):
|
||||
redact_credential(accessory, key)
|
||||
|
||||
# publish destinations carry stream keys in the URL path
|
||||
if isinstance(go2rtc.get("publish"), dict):
|
||||
for name in list(go2rtc["publish"]):
|
||||
redact_credential(go2rtc["publish"], name)
|
||||
|
||||
|
||||
@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_profiles(request: Request):
|
||||
"""List all available profiles and the currently active profile."""
|
||||
@@ -478,8 +429,6 @@ def ffmpeg_presets():
|
||||
hwaccel_presets = [
|
||||
"preset-rpi-64-h264",
|
||||
"preset-rpi-64-h265",
|
||||
"preset-apple-silicon-h264",
|
||||
"preset-apple-silicon-h265",
|
||||
"preset-jetson-h264",
|
||||
"preset-jetson-h265",
|
||||
"preset-rkmpp",
|
||||
@@ -574,63 +523,6 @@ def config_raw():
|
||||
)
|
||||
|
||||
|
||||
def _go2rtc_streams(raw_config: str) -> Any:
|
||||
"""Load go2rtc.streams with the same loader Frigate and go2rtc startup use.
|
||||
|
||||
Raises ruamel.yaml.YAMLError when the config cannot be parsed.
|
||||
"""
|
||||
loaded = ruamel.yaml.YAML().load(raw_config)
|
||||
go2rtc = loaded.get("go2rtc") if isinstance(loaded, dict) else None
|
||||
return go2rtc.get("streams") if isinstance(go2rtc, dict) else None
|
||||
|
||||
|
||||
def _restricted_go2rtc_streams_response(
|
||||
old_raw_config: str, new_raw_config: str
|
||||
) -> JSONResponse | None:
|
||||
"""Reject config changes that add restricted go2rtc stream sources.
|
||||
|
||||
The same sources are rejected by PUT /go2rtc/streams, and go2rtc would
|
||||
otherwise load them from the saved config on the next restart.
|
||||
"""
|
||||
try:
|
||||
new_streams = _go2rtc_streams(new_raw_config)
|
||||
except ruamel.yaml.YAMLError:
|
||||
logger.warning("Rejected config change that could not be parsed")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unable to parse config"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
old_streams = _go2rtc_streams(old_raw_config)
|
||||
except ruamel.yaml.YAMLError:
|
||||
# with no readable saved config, every source is treated as new
|
||||
old_streams = None
|
||||
|
||||
issues = find_restricted_go2rtc_stream_changes(old_streams, new_streams)
|
||||
if not issues:
|
||||
return None
|
||||
|
||||
messages = []
|
||||
for issue, label in (
|
||||
(Go2rtcStreamIssue.restricted, "Restricted go2rtc stream source in"),
|
||||
(Go2rtcStreamIssue.unknown_variable, "Unknown variable in go2rtc stream"),
|
||||
(
|
||||
Go2rtcStreamIssue.invalid_placeholder,
|
||||
"Invalid variable placeholder in go2rtc stream",
|
||||
),
|
||||
):
|
||||
names = [name for name, found in issues.items() if found == issue]
|
||||
if names:
|
||||
messages.append(f"{label}: {', '.join(names)}")
|
||||
|
||||
logger.warning("Rejected config change: %s", "; ".join(messages))
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "; ".join(messages)},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config/save", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
new_config = body.decode()
|
||||
@@ -707,22 +599,10 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
config_file = find_config_file()
|
||||
|
||||
try:
|
||||
with open(config_file) as f:
|
||||
old_raw_config = f.read()
|
||||
except OSError:
|
||||
old_raw_config = ""
|
||||
|
||||
restricted_response = _restricted_go2rtc_streams_response(
|
||||
old_raw_config, new_config
|
||||
)
|
||||
if restricted_response is not None:
|
||||
return restricted_response
|
||||
|
||||
# Save the config to file
|
||||
try:
|
||||
config_file = find_config_file()
|
||||
|
||||
with open(config_file, "w") as f:
|
||||
f.write(new_config)
|
||||
f.close()
|
||||
@@ -768,24 +648,17 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
)
|
||||
|
||||
|
||||
def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
|
||||
"""Substitute incoming masked camera paths with the saved ones.
|
||||
def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None:
|
||||
"""Substitute incoming `*:*` masked credentials with the in-memory ones.
|
||||
|
||||
The /config response masks ffmpeg input credentials, so the settings UI
|
||||
sends the masked path back when sibling fields (e.g. hwaccel_args) are
|
||||
edited. Each masked path is matched to the saved input that masks to the
|
||||
same value, with roles breaking a tie, so deleting or reordering inputs
|
||||
keeps each path's own credentials. The saved path is restored as written,
|
||||
keeping any {FRIGATE_*} placeholder. Mutates `config_data` in place and
|
||||
returns an error message when a masked path has no single match.
|
||||
edited. Without this we'd write `rtsp://*:*@host` into YAML and lose
|
||||
the real credentials. Mutates `config_data` in place.
|
||||
"""
|
||||
cameras = config_data.get("cameras")
|
||||
if not isinstance(cameras, dict):
|
||||
return None
|
||||
|
||||
saved_cameras = saved.get("cameras")
|
||||
if not isinstance(saved_cameras, dict):
|
||||
saved_cameras = {}
|
||||
return
|
||||
|
||||
for camera_name, camera_data in cameras.items():
|
||||
if not isinstance(camera_data, dict):
|
||||
@@ -793,122 +666,20 @@ def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
|
||||
inputs = camera_data.get("ffmpeg", {}).get("inputs")
|
||||
if not isinstance(inputs, list):
|
||||
continue
|
||||
|
||||
saved_camera = saved_cameras.get(camera_name)
|
||||
saved_ffmpeg = (
|
||||
saved_camera.get("ffmpeg") if isinstance(saved_camera, dict) else None
|
||||
)
|
||||
saved_inputs = [
|
||||
inp
|
||||
for inp in (
|
||||
saved_ffmpeg.get("inputs") if isinstance(saved_ffmpeg, dict) else None
|
||||
)
|
||||
or []
|
||||
if isinstance(inp, dict) and isinstance(inp.get("path"), str)
|
||||
]
|
||||
|
||||
existing = config.cameras.get(camera_name)
|
||||
if existing is None:
|
||||
continue
|
||||
existing_paths = [inp.path for inp in existing.ffmpeg.inputs]
|
||||
for index, input_obj in enumerate(inputs):
|
||||
if not isinstance(input_obj, dict):
|
||||
continue
|
||||
path = input_obj.get("path")
|
||||
if not isinstance(path, str) or not is_masked_camera_path(path):
|
||||
if not isinstance(path, str):
|
||||
continue
|
||||
candidates = [
|
||||
inp
|
||||
for inp in saved_inputs
|
||||
if clean_camera_user_pass(_resolve_frigate_vars(inp["path"])) == path
|
||||
]
|
||||
if len({inp["path"] for inp in candidates}) > 1:
|
||||
candidates = [
|
||||
inp
|
||||
for inp in candidates
|
||||
if inp.get("roles") == input_obj.get("roles")
|
||||
]
|
||||
real_paths = {inp["path"] for inp in candidates}
|
||||
if len(real_paths) != 1:
|
||||
return (
|
||||
f"Input {index} of camera {camera_name} has masked credentials "
|
||||
"that could not be matched; resend it with real credentials"
|
||||
)
|
||||
input_obj["path"] = real_paths.pop()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_frigate_vars(value: str) -> str:
|
||||
"""Resolve {FRIGATE_*} placeholders the way /config shows them."""
|
||||
try:
|
||||
return substitute_frigate_vars(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]:
|
||||
"""Shape the in-memory camera inputs like the saved YAML."""
|
||||
return {
|
||||
"cameras": {
|
||||
name: {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": inp.path, "roles": [role.value for role in inp.roles]}
|
||||
for inp in camera.ffmpeg.inputs
|
||||
]
|
||||
}
|
||||
}
|
||||
for name, camera in config.cameras.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _config_data_to_updates(
|
||||
config_data: dict, saved: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
"""Flatten a /config/set payload after restoring masked credentials.
|
||||
|
||||
`saved` is the config the masked values were taken from. Returns the flat
|
||||
updates, or an error message when a masked value can't be restored.
|
||||
"""
|
||||
error = _restore_masked_camera_paths(config_data, saved)
|
||||
if error:
|
||||
return {}, error
|
||||
|
||||
updates = flatten_config_data(config_data)
|
||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop sentinel-valued fields so untouched credential placeholders don't
|
||||
# clobber the saved YAML value.
|
||||
updates = {k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL}
|
||||
|
||||
error = _find_masked_update(updates)
|
||||
if error:
|
||||
return {}, error
|
||||
|
||||
return updates, None
|
||||
|
||||
|
||||
def _holds_masked_value(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return REDACTED_CREDENTIAL_SENTINEL in value or is_masked_camera_path(value)
|
||||
if isinstance(value, dict):
|
||||
return any(_holds_masked_value(v) for v in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_holds_masked_value(v) for v in value)
|
||||
return False
|
||||
|
||||
|
||||
def _find_masked_update(updates: dict[str, Any]) -> str | None:
|
||||
"""Return an error naming the first update that still holds a masked value.
|
||||
|
||||
Runs after the restores so a masked path or redaction sentinel that could
|
||||
not be restored is never written over the real credential.
|
||||
"""
|
||||
for key, value in updates.items():
|
||||
if _holds_masked_value(value):
|
||||
return (
|
||||
f"{key} contains masked or saved credentials; "
|
||||
"resend it with real values"
|
||||
)
|
||||
return None
|
||||
if ("://*:*@" in path or "user=*&password=*" in path) and index < len(
|
||||
existing_paths
|
||||
):
|
||||
input_obj["path"] = existing_paths[index]
|
||||
|
||||
|
||||
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
||||
@@ -921,13 +692,13 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
try:
|
||||
updates = {}
|
||||
if body.config_data:
|
||||
updates, error = _config_data_to_updates(
|
||||
body.config_data, _saved_config_from_memory(request.app.frigate_config)
|
||||
)
|
||||
if error:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": error}, status_code=400
|
||||
)
|
||||
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop any field whose value is still the redaction sentinel
|
||||
updates = {
|
||||
k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL
|
||||
}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
@@ -1037,17 +808,6 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
)
|
||||
|
||||
|
||||
def _config_path_exists(data: Any, key_path: str) -> bool:
|
||||
"""Return whether a dotted config path is present in parsed yaml."""
|
||||
for key in split_config_key_path(key_path):
|
||||
if not isinstance(data, dict) or key not in data:
|
||||
return False
|
||||
|
||||
data = data[key]
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_set(request: Request, body: AppConfigSetBody):
|
||||
config_file = find_config_file()
|
||||
@@ -1074,26 +834,22 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
# Filter out empty keys but keep blank values for non-empty keys
|
||||
query_string = {k: v for k, v in query_string.items() if k}
|
||||
|
||||
error = None
|
||||
|
||||
if query_string:
|
||||
updates = process_config_query_string(query_string)
|
||||
error = _find_masked_update(updates)
|
||||
elif body.config_data:
|
||||
try:
|
||||
saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config)
|
||||
except ruamel.yaml.YAMLError:
|
||||
saved_config = None
|
||||
updates, error = _config_data_to_updates(
|
||||
body.config_data,
|
||||
saved_config if isinstance(saved_config, dict) else {},
|
||||
)
|
||||
|
||||
if error:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": error},
|
||||
status_code=400,
|
||||
_restore_masked_camera_paths(
|
||||
body.config_data, request.app.frigate_config
|
||||
)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop sentinel-valued fields so untouched credential
|
||||
# placeholders don't clobber the saved YAML value.
|
||||
updates = {
|
||||
k: v
|
||||
for k, v in updates.items()
|
||||
if v != REDACTED_CREDENTIAL_SENTINEL
|
||||
}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
@@ -1106,19 +862,6 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# delete replaced paths first so their maps are rewritten in
|
||||
# the order sent; update_yaml would otherwise keep old order
|
||||
if body.replace_paths:
|
||||
old_yaml = ruamel.yaml.YAML(typ="safe").load(old_raw_config) or {}
|
||||
updates = {
|
||||
**{
|
||||
path: ""
|
||||
for path in body.replace_paths
|
||||
if _config_path_exists(old_yaml, path)
|
||||
},
|
||||
**updates,
|
||||
}
|
||||
|
||||
# apply all updates in a single operation
|
||||
update_yaml_file_bulk(config_file, updates)
|
||||
|
||||
@@ -1170,13 +913,6 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
restricted_response = _restricted_go2rtc_streams_response(
|
||||
old_raw_config, new_raw_config
|
||||
)
|
||||
if restricted_response is not None:
|
||||
with open(config_file, "w") as f:
|
||||
f.write(old_raw_config)
|
||||
return restricted_response
|
||||
except Exception as e:
|
||||
logging.error(f"Error updating config: {e}")
|
||||
return JSONResponse(
|
||||
@@ -1189,15 +925,9 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
|
||||
|
||||
go2rtc_synced = True
|
||||
|
||||
if body.requires_restart == 0 or body.update_topic:
|
||||
old_config: FrigateConfig = request.app.frigate_config
|
||||
swap_runtime_config(request.app, config)
|
||||
go2rtc_synced = sync_transcode_streams(
|
||||
generated_transcode_streams(old_config),
|
||||
generated_transcode_streams(config),
|
||||
)
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
@@ -1257,7 +987,6 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if body.requires_restart == 0
|
||||
else "Config successfully updated, restart to apply"
|
||||
),
|
||||
"go2rtc_synced": go2rtc_synced,
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
|
||||
+79
-150
@@ -12,9 +12,8 @@ import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from joserfc import jwt
|
||||
@@ -32,8 +31,6 @@ from frigate.api.media_auth import (
|
||||
check_camera_access,
|
||||
deny_response_for_media_uri,
|
||||
is_role_restricted,
|
||||
normalize_request_path,
|
||||
split_request_uri,
|
||||
)
|
||||
from frigate.config import AuthConfig, ProxyConfig
|
||||
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
||||
@@ -133,18 +130,23 @@ def require_admin_by_default():
|
||||
if path.startswith(EXEMPT_PREFIXES):
|
||||
return
|
||||
|
||||
# Camera routes enforce per-camera access via route-level dependencies
|
||||
# (e.g. require_camera_access). Match on the route template, not the raw
|
||||
# path, so a camera named like another namespace (e.g. "faces") can't
|
||||
# waive the admin check for that namespace's routes.
|
||||
route = request.scope.get("route")
|
||||
if (
|
||||
route is not None
|
||||
and route.path.startswith("/{camera_name}")
|
||||
and request.path_params.get("camera_name")
|
||||
in request.app.frigate_config.cameras
|
||||
):
|
||||
return
|
||||
# Dynamic camera path exemption:
|
||||
# Any path whose first segment matches a configured camera name should
|
||||
# bypass the global admin requirement. These endpoints enforce access
|
||||
# via route-level dependencies (e.g. require_camera_access) to ensure
|
||||
# per-camera authorization. This allows non-admin authenticated users
|
||||
# (e.g. viewer role) to access camera-specific resources without
|
||||
# needing admin privileges.
|
||||
try:
|
||||
if path.startswith("/"):
|
||||
first_segment = path.split("/", 2)[1]
|
||||
if (
|
||||
first_segment
|
||||
and first_segment in request.app.frigate_config.cameras
|
||||
):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# For all other paths, require admin role
|
||||
# Internal port requests have admin role set automatically
|
||||
@@ -316,19 +318,17 @@ def get_remote_addr(request: Request):
|
||||
|
||||
route = list(reversed(forwarded_for.split(",")))
|
||||
logger.debug(f"IP Route: {[r for r in route]}")
|
||||
trusted_proxies = [
|
||||
ipaddress.ip_network(proxy, strict=False)
|
||||
for proxy in request.app.frigate_config.auth.trusted_proxies
|
||||
]
|
||||
trusted_proxies = []
|
||||
for proxy in request.app.frigate_config.auth.trusted_proxies:
|
||||
try:
|
||||
network = ipaddress.ip_network(proxy)
|
||||
except ValueError:
|
||||
logger.warning(f"Unable to parse trusted network: {proxy}")
|
||||
trusted_proxies.append(network)
|
||||
|
||||
# return the first remote address that is not trusted
|
||||
for addr in route:
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr.strip())
|
||||
except ValueError:
|
||||
logger.debug("Invalid address in X-Forwarded-For header")
|
||||
return direct_addr or "127.0.0.1"
|
||||
|
||||
ip = ipaddress.ip_address(addr.strip())
|
||||
logger.debug(f"Checking {ip} (v{ip.version})")
|
||||
trusted = False
|
||||
for trusted_proxy in trusted_proxies:
|
||||
@@ -471,42 +471,14 @@ def create_encoded_jwt(user, role, expiration, secret):
|
||||
)
|
||||
|
||||
|
||||
def _user_session(user: User) -> tuple[str, int | None]:
|
||||
changed_at = user.password_changed_at
|
||||
|
||||
# floored like iat, so a login in the same second as the change stays valid
|
||||
return user.role, int(changed_at.timestamp()) if changed_at else None
|
||||
|
||||
|
||||
def load_user_sessions() -> dict[str, tuple[str, int | None]]:
|
||||
"""Map each username to its role and password change time for /auth."""
|
||||
return {user.username: _user_session(user) for user in User.select()}
|
||||
|
||||
|
||||
def refresh_user_session(request: Request, username: str) -> None:
|
||||
"""Sync the /auth session map after a user is created, changed or deleted."""
|
||||
user = User.get_or_none(User.username == username)
|
||||
if user is None:
|
||||
request.app.user_sessions.pop(username, None)
|
||||
else:
|
||||
request.app.user_sessions[username] = _user_session(user)
|
||||
|
||||
|
||||
def is_secure_request(request: Request) -> bool:
|
||||
"""Check the cookie setting and nginx's forwarded scheme for HTTPS."""
|
||||
# nginx sets X-Forwarded-Proto to its own scheme, replacing any client value
|
||||
return (
|
||||
request.app.frigate_config.auth.cookie_secure
|
||||
or request.headers.get("x-forwarded-proto") == "https"
|
||||
)
|
||||
|
||||
|
||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
|
||||
# Starlette sets SameSite=Lax by default. The cookie is still sent to
|
||||
# same-site iframes (e.g. Home Assistant on the same host or domain), but
|
||||
# not to cross-site ones. CSRF is also mitigated by requiring a custom
|
||||
# X-CSRF-TOKEN header, which cross-origin pages cannot set without a CORS
|
||||
# preflight that Frigate never grants (see check_csrf in api/fastapi_app.py).
|
||||
# TODO: ideally this would set secure as well, but that requires TLS
|
||||
# SameSite is intentionally left unset (browsers default to Lax). Setting
|
||||
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin
|
||||
# iframes, breaking embedded views such as the Home Assistant Frigate card.
|
||||
# CSRF is instead mitigated by requiring a custom X-CSRF-TOKEN header, which
|
||||
# cross-origin pages cannot set without a CORS preflight that Frigate never
|
||||
# grants (see check_csrf in api/fastapi_app.py).
|
||||
response.set_cookie(
|
||||
key=cookie_name,
|
||||
value=encoded_jwt,
|
||||
@@ -779,6 +751,7 @@ def auth(request: Request):
|
||||
fail_response.headers["location"] = "/login"
|
||||
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||
JWT_REFRESH = request.app.frigate_config.auth.refresh_time
|
||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||
|
||||
@@ -804,27 +777,20 @@ def auth(request: Request):
|
||||
if "sub" not in token.claims:
|
||||
logger.debug("user not set in jwt token")
|
||||
return fail_response
|
||||
if "role" not in token.claims:
|
||||
logger.debug("role not set in jwt token")
|
||||
return fail_response
|
||||
if "exp" not in token.claims:
|
||||
logger.debug("exp not set in jwt token")
|
||||
return fail_response
|
||||
|
||||
user = token.claims.get("sub")
|
||||
session = request.app.user_sessions.get(user)
|
||||
if session is None:
|
||||
logger.debug("jwt user %s no longer exists", user)
|
||||
return fail_response
|
||||
|
||||
role, password_changed_at = session
|
||||
if password_changed_at is not None and (
|
||||
int(token.claims.get("iat", 0)) < password_changed_at
|
||||
):
|
||||
logger.debug("jwt token issued before password change")
|
||||
return fail_response
|
||||
role = token.claims.get("role")
|
||||
|
||||
# the token keeps the role it was issued with, so a role removed from
|
||||
# the config since then must send the user back through login
|
||||
if role not in auth_config.roles:
|
||||
logger.debug(
|
||||
"jwt user %s has role %s, which is not in the config", user, role
|
||||
)
|
||||
logger.debug("jwt role %s is not in the config", role)
|
||||
return fail_response
|
||||
|
||||
current_time = int(time.time())
|
||||
@@ -847,6 +813,25 @@ def auth(request: Request):
|
||||
# if the jwt cookie is expiring soon
|
||||
if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
|
||||
logger.debug("jwt token expiring soon, refreshing cookie")
|
||||
|
||||
# Check if password has been changed since token was issued
|
||||
# If so, force re-login by rejecting the refresh
|
||||
try:
|
||||
user_obj = User.get_by_id(user)
|
||||
if user_obj.password_changed_at is not None:
|
||||
token_iat = int(token.claims.get("iat", 0))
|
||||
password_changed_timestamp = int(
|
||||
user_obj.password_changed_at.timestamp()
|
||||
)
|
||||
if token_iat < password_changed_timestamp:
|
||||
logger.debug(
|
||||
"jwt token issued before password change, rejecting refresh"
|
||||
)
|
||||
return fail_response
|
||||
except DoesNotExist:
|
||||
logger.debug("user not found")
|
||||
return fail_response
|
||||
|
||||
new_expiration = current_time + JWT_SESSION_LENGTH
|
||||
new_encoded_jwt = create_encoded_jwt(
|
||||
user, role, new_expiration, request.app.jwt_token
|
||||
@@ -856,7 +841,7 @@ def auth(request: Request):
|
||||
JWT_COOKIE_NAME,
|
||||
new_encoded_jwt,
|
||||
JWT_SESSION_LENGTH,
|
||||
is_secure_request(request),
|
||||
JWT_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
success_response.headers["remote-user"] = user
|
||||
@@ -945,6 +930,7 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
)
|
||||
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||
user = body.user
|
||||
password = body.password
|
||||
@@ -960,16 +946,13 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
|
||||
password_hash = db_user.password_hash
|
||||
if verify_password(password, password_hash):
|
||||
role = db_user.role
|
||||
if role not in request.app.frigate_config.auth.roles:
|
||||
role = getattr(db_user, "role", "viewer")
|
||||
config_roles_set = set(request.app.frigate_config.auth.roles.keys())
|
||||
if role not in config_roles_set:
|
||||
logger.warning(
|
||||
"Login refused for user '%s' with role '%s', which is not in the config",
|
||||
user,
|
||||
role,
|
||||
f"User {db_user.username} has an invalid role {role}, falling back to 'viewer'."
|
||||
)
|
||||
failed_logins.record(user, time.time(), known=True)
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
role = "viewer"
|
||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
|
||||
response = Response("", 200)
|
||||
@@ -978,7 +961,7 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
JWT_COOKIE_NAME,
|
||||
encoded_jwt,
|
||||
JWT_SESSION_LENGTH,
|
||||
is_secure_request(request),
|
||||
JWT_COOKIE_SECURE,
|
||||
)
|
||||
# Clear admin_first_time_login flag after successful admin login so the
|
||||
# UI stops showing the first-time login documentation link.
|
||||
@@ -1020,7 +1003,7 @@ def create_user(
|
||||
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
||||
config_roles = list(request.app.frigate_config.auth.roles.keys())
|
||||
|
||||
if not re.match(r"^(?!\.+$)[A-Za-z0-9._]+$", body.username):
|
||||
if not re.match("^[A-Za-z0-9._]+$", body.username):
|
||||
return JSONResponse(content={"message": "Invalid username"}, status_code=400)
|
||||
|
||||
if body.role not in config_roles:
|
||||
@@ -1044,11 +1027,9 @@ def create_user(
|
||||
User.username: body.username,
|
||||
User.password_hash: password_hash,
|
||||
User.role: role,
|
||||
User.password_changed_at: datetime.now(),
|
||||
User.notification_tokens: [],
|
||||
}
|
||||
).execute()
|
||||
refresh_user_session(request, body.username)
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"username": body.username})
|
||||
|
||||
@@ -1067,7 +1048,6 @@ def delete_user(request: Request, username: str):
|
||||
)
|
||||
|
||||
User.delete_by_id(username)
|
||||
refresh_user_session(request, username)
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
@@ -1135,13 +1115,13 @@ async def update_password(
|
||||
User.password_changed_at: datetime.now(),
|
||||
}
|
||||
).where(User.username == username).execute()
|
||||
refresh_user_session(request, username)
|
||||
|
||||
response = JSONResponse(content={"success": True})
|
||||
|
||||
# If user changed their own password, issue a new JWT to keep them logged in
|
||||
if current_username == username:
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||
|
||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||
@@ -1154,7 +1134,7 @@ async def update_password(
|
||||
JWT_COOKIE_NAME,
|
||||
encoded_jwt,
|
||||
JWT_SESSION_LENGTH,
|
||||
is_secure_request(request),
|
||||
JWT_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -1194,7 +1174,6 @@ async def update_role(
|
||||
)
|
||||
|
||||
User.set_by_id(username, {User.role: body.role})
|
||||
refresh_user_session(request, username)
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
@@ -1250,44 +1229,11 @@ def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:
|
||||
return owner_cameras
|
||||
|
||||
|
||||
def _is_existing_go2rtc_stream(request: Request, stream_name: str) -> bool:
|
||||
"""Check that `stream_name` names a stream go2rtc already knows about.
|
||||
|
||||
go2rtc turns any `src` that is not an existing stream name into a new ad hoc
|
||||
stream and starts the producer it describes, so the proxied live-stream
|
||||
paths must only ever reference existing streams.
|
||||
"""
|
||||
frigate_config = request.app.frigate_config
|
||||
|
||||
if _get_stream_owner_cameras(request, stream_name):
|
||||
return True
|
||||
|
||||
config_streams = getattr(frigate_config.go2rtc, "streams", None) or {}
|
||||
if stream_name in config_streams:
|
||||
return True
|
||||
|
||||
# streams added at runtime through PUT /go2rtc/streams only exist in go2rtc
|
||||
try:
|
||||
r = requests.get("http://127.0.0.1:1984/api/streams", timeout=2)
|
||||
except requests.RequestException:
|
||||
logger.warning("Unable to fetch streams from go2rtc to authorize request")
|
||||
return False
|
||||
|
||||
if not r.ok:
|
||||
return False
|
||||
|
||||
try:
|
||||
return stream_name in r.json()
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# nginx proxies these paths straight to go2rtc with authentication-only checks
|
||||
# (see auth_request.conf). Each names the desired stream via the `src` query
|
||||
# param, so the camera-level check must happen here in the `/auth` subrequest —
|
||||
# `require_go2rtc_stream_access` only guards the REST `/go2rtc/streams/{name}`
|
||||
# endpoint, not these proxied live-stream paths. The nginx locations are prefix
|
||||
# matches, so anything under these paths is proxied too.
|
||||
# endpoint, not these proxied live-stream paths.
|
||||
GO2RTC_STREAM_PROXY_PATHS = frozenset(
|
||||
{
|
||||
"/live/mse/api/ws",
|
||||
@@ -1297,48 +1243,31 @@ GO2RTC_STREAM_PROXY_PATHS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def _is_go2rtc_stream_proxy_request(path: str, request: Request) -> bool:
|
||||
# nginx marks requests routed through the go2rtc stream locations, and the
|
||||
# normalized path catches them if that marker is ever missing
|
||||
if request.headers.get("x-go2rtc-stream-proxy") == "1":
|
||||
return True
|
||||
|
||||
normalized = normalize_request_path(path)
|
||||
return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS)
|
||||
|
||||
|
||||
def deny_response_for_go2rtc_stream(
|
||||
original_url: str | None, role: str | None, request: Request
|
||||
) -> int | None:
|
||||
"""Block go2rtc live-stream requests the current role may not make.
|
||||
"""Block role-restricted users from go2rtc live streams they cannot access.
|
||||
|
||||
Returns 403 when any `src` named in `original_url` is not an existing go2rtc
|
||||
stream (for every role), or when it resolves to a camera outside a
|
||||
restricted role's allow-list (or no `src` is provided for such a role),
|
||||
otherwise None. Mirrors the resolution logic in
|
||||
Returns 403 when any `src` stream named in `original_url` resolves to a
|
||||
camera outside the role's allow-list (or when no `src` is provided on a
|
||||
stream-proxy path), otherwise None. Mirrors the resolution logic in
|
||||
`require_go2rtc_stream_access` so substream names map to their owning
|
||||
camera correctly.
|
||||
"""
|
||||
if not original_url:
|
||||
return None
|
||||
|
||||
path, query = split_request_uri(original_url)
|
||||
if not _is_go2rtc_stream_proxy_request(path, request):
|
||||
parsed = urlparse(original_url)
|
||||
if parsed.path not in GO2RTC_STREAM_PROXY_PATHS:
|
||||
return None
|
||||
|
||||
frigate_config = request.app.frigate_config
|
||||
sources = parse_qs(query, keep_blank_values=True).get("src", [])
|
||||
|
||||
# an unknown src would make go2rtc create an ad hoc stream from it, which
|
||||
# bypasses the restricted-source checks on PUT /go2rtc/streams
|
||||
for src in sources:
|
||||
if not _is_existing_go2rtc_stream(request, src):
|
||||
return 403
|
||||
|
||||
# admin and full-access roles (no allow-list) bypass the camera check
|
||||
if not role or not is_role_restricted(role, frigate_config):
|
||||
return None
|
||||
|
||||
sources = parse_qs(parsed.query).get("src", [])
|
||||
if not sources:
|
||||
# a stream-proxy request naming no stream has nothing legitimate to
|
||||
# show a restricted user
|
||||
|
||||
+7
-51
@@ -33,25 +33,16 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.env import UnknownVariableError, substitute_frigate_vars
|
||||
from frigate.models import User
|
||||
from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
clear_orphaned_comments,
|
||||
get_record_segment_time,
|
||||
)
|
||||
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.env import UnknownVariableError, substitute_frigate_vars
|
||||
from frigate.util.image import run_ffmpeg_snapshot
|
||||
from frigate.util.live_streams import (
|
||||
generated_transcode_streams,
|
||||
measure_stream_bitrate,
|
||||
sync_transcode_streams,
|
||||
)
|
||||
from frigate.util.services import (
|
||||
analyze_record_keyframes,
|
||||
ffprobe_stream,
|
||||
is_restricted_go2rtc_api_source,
|
||||
is_restricted_go2rtc_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -157,9 +148,9 @@ def go2rtc_camera_stream(request: Request, stream_name: str):
|
||||
)
|
||||
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
||||
"""Add or update a go2rtc stream configuration."""
|
||||
if src and is_restricted_go2rtc_api_source(src):
|
||||
if src and is_restricted_go2rtc_source(src):
|
||||
logger.warning(
|
||||
"Rejected go2rtc stream '%s' with restricted source",
|
||||
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
|
||||
stream_name,
|
||||
)
|
||||
return JSONResponse(
|
||||
@@ -178,9 +169,9 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
||||
except UnknownVariableError:
|
||||
resolved_src = src
|
||||
|
||||
if is_restricted_go2rtc_api_source(resolved_src):
|
||||
if is_restricted_go2rtc_source(resolved_src):
|
||||
logger.warning(
|
||||
"Rejected go2rtc stream '%s' with restricted source",
|
||||
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
|
||||
stream_name,
|
||||
)
|
||||
return JSONResponse(
|
||||
@@ -257,34 +248,6 @@ def go2rtc_delete_stream(stream_name: str):
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/go2rtc/streams/{stream_name}/bitrate",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
async def go2rtc_stream_bitrate(request: Request, stream_name: str):
|
||||
"""Measure a go2rtc stream's bitrate over a few seconds."""
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
known = set(config.go2rtc.model_dump().get("streams") or {}) | set(
|
||||
generated_transcode_streams(config)
|
||||
)
|
||||
|
||||
if stream_name not in known:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unknown stream"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
kbps = await asyncio.to_thread(measure_stream_bitrate, stream_name)
|
||||
|
||||
if kbps is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Stream sent no data"},
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "kbps": round(kbps)})
|
||||
|
||||
|
||||
@router.get("/ffprobe", dependencies=[Depends(require_role(["admin"]))])
|
||||
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||
path_param = paths
|
||||
@@ -1277,7 +1240,6 @@ async def delete_camera(
|
||||
# Remove camera from config
|
||||
if "cameras" in data and camera_name in data["cameras"]:
|
||||
del data["cameras"][camera_name]
|
||||
clear_orphaned_comments(data["cameras"], data, "cameras")
|
||||
|
||||
# Remove camera from auth roles
|
||||
auth = data.get("auth", {})
|
||||
@@ -1380,12 +1342,6 @@ async def delete_camera(
|
||||
except Exception:
|
||||
logger.debug("Failed to remove go2rtc stream for %s", camera_name)
|
||||
|
||||
await asyncio.to_thread(
|
||||
sync_transcode_streams,
|
||||
generated_transcode_streams(frigate_config),
|
||||
generated_transcode_streams(request.app.frigate_config),
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Object classification APIs."""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
@@ -49,9 +48,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.classification])
|
||||
|
||||
# concurrent collections for one model share its temp and train directories
|
||||
example_collection_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def invalid_name_response(value: str) -> JSONResponse:
|
||||
"""Response for a name that cannot be used as a path component."""
|
||||
@@ -1294,10 +1290,7 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
|
||||
if model_name is None:
|
||||
return invalid_name_response(body.model_name)
|
||||
|
||||
async with example_collection_lock:
|
||||
await asyncio.to_thread(
|
||||
collect_object_classification_examples, model_name, body.label
|
||||
)
|
||||
collect_object_classification_examples(model_name, body.label)
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Example generation completed"},
|
||||
|
||||
@@ -51,7 +51,6 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
||||
|
||||
if app.stats_emitter is not None:
|
||||
app.stats_emitter.config = config
|
||||
app.stats_emitter.hardware_stats.set_config(config)
|
||||
|
||||
if app.dispatcher is not None:
|
||||
app.dispatcher.config = config
|
||||
|
||||
@@ -7,4 +7,4 @@ class AppTimelineHourlyQueryParameters(BaseModel):
|
||||
after: float | None = None
|
||||
before: float | None = None
|
||||
limit: int | None = 200
|
||||
timezone: str | None = "UTC"
|
||||
timezone: str | None = "utc"
|
||||
|
||||
@@ -14,7 +14,6 @@ class EventsQueryParams(BaseModel):
|
||||
zone: str | None = "all"
|
||||
zones: str | None = "all"
|
||||
limit: int | None = 100
|
||||
offset: int | None = Field(0, ge=0)
|
||||
after: float | None = None
|
||||
before: float | None = None
|
||||
time_range: str | None = DEFAULT_TIME_RANGE
|
||||
@@ -40,7 +39,7 @@ class EventsQueryParams(BaseModel):
|
||||
max_length: float | None = None
|
||||
event_id: str | None = None
|
||||
sort: str | None = None
|
||||
timezone: str | None = "UTC"
|
||||
timezone: str | None = "utc"
|
||||
|
||||
|
||||
class EventsSearchQueryParams(BaseModel):
|
||||
@@ -56,7 +55,6 @@ class EventsSearchQueryParams(BaseModel):
|
||||
deprecated=True,
|
||||
)
|
||||
limit: int | None = 50
|
||||
offset: int | None = Field(0, ge=0)
|
||||
cameras: str | None = "all"
|
||||
labels: str | None = "all"
|
||||
sub_labels: str | None = "all"
|
||||
@@ -68,7 +66,7 @@ class EventsSearchQueryParams(BaseModel):
|
||||
has_clip: bool | None = None
|
||||
has_snapshot: bool | None = None
|
||||
is_submitted: bool | None = None
|
||||
timezone: str | None = "UTC"
|
||||
timezone: str | None = "utc"
|
||||
min_score: float | None = None
|
||||
max_score: float | None = None
|
||||
min_speed: float | None = None
|
||||
@@ -78,6 +76,6 @@ class EventsSearchQueryParams(BaseModel):
|
||||
|
||||
|
||||
class EventsSummaryQueryParams(BaseModel):
|
||||
timezone: str | None = "UTC"
|
||||
timezone: str | None = "utc"
|
||||
has_clip: int | None = None
|
||||
has_snapshot: int | None = None
|
||||
|
||||
@@ -3,7 +3,7 @@ from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
|
||||
class MediaRecordingsSummaryQueryParams(BaseModel):
|
||||
timezone: str = "UTC"
|
||||
timezone: str = "utc"
|
||||
cameras: str | None = "all"
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class ReviewSummaryQueryParams(BaseModel):
|
||||
cameras: str = "all"
|
||||
labels: str = "all"
|
||||
zones: str = "all"
|
||||
timezone: str = "UTC"
|
||||
timezone: str = "utc"
|
||||
|
||||
|
||||
class ReviewActivityMotionQueryParams(BaseModel):
|
||||
|
||||
@@ -10,8 +10,6 @@ class AppConfigSetBody(BaseModel):
|
||||
update_topic: str | None = None
|
||||
config_data: dict[str, Any] | None = None
|
||||
skip_save: bool = False
|
||||
# paths rewritten whole, so a map saves in the order sent
|
||||
replace_paths: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GenAIProbeBody(BaseModel):
|
||||
|
||||
+2
-11
@@ -129,7 +129,6 @@ def events(
|
||||
zones = zone
|
||||
|
||||
limit = params.limit
|
||||
offset = params.offset
|
||||
after = params.after
|
||||
before = params.before
|
||||
time_range = params.time_range
|
||||
@@ -362,15 +361,11 @@ def events(
|
||||
else:
|
||||
order_by = Event.start_time.desc()
|
||||
|
||||
# offset paging needs a stable order when scores or speeds tie
|
||||
tiebreaker = [Event.id] if sort and sort.startswith(("score", "speed")) else []
|
||||
|
||||
events = (
|
||||
Event.select(*selected_columns)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.order_by(order_by, *tiebreaker)
|
||||
.order_by(order_by)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
@@ -539,7 +534,6 @@ def events_search(
|
||||
search_type = params.search_type
|
||||
include_thumbnails = params.include_thumbnails
|
||||
limit = params.limit
|
||||
offset = params.offset
|
||||
sort = params.sort
|
||||
|
||||
# Filters
|
||||
@@ -846,9 +840,6 @@ def events_search(
|
||||
if search_results:
|
||||
events_query = events_query.where(Event.id << list(search_results.keys()))
|
||||
|
||||
# sorts below are stable, so this orders ties for offset paging
|
||||
events_query = events_query.order_by(Event.id)
|
||||
|
||||
# Fetch events and process them in a single pass
|
||||
processed_events = []
|
||||
for event in events_query.dicts():
|
||||
@@ -906,7 +897,7 @@ def events_search(
|
||||
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
|
||||
|
||||
# Limit the number of events returned
|
||||
processed_events = processed_events[offset:][:limit]
|
||||
processed_events = processed_events[:limit]
|
||||
|
||||
return JSONResponse(content=processed_events)
|
||||
|
||||
|
||||
+15
-21
@@ -58,7 +58,6 @@ from frigate.api.defs.response.export_response import (
|
||||
)
|
||||
from frigate.api.defs.response.generic_response import GenericResponse
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.media_auth import deny_response_for_media_uri
|
||||
from frigate.const import CLIPS_DIR, EXPORT_DIR
|
||||
from frigate.jobs.export import (
|
||||
ExportJob,
|
||||
@@ -80,7 +79,6 @@ from frigate.record.export import (
|
||||
validate_ffmpeg_args,
|
||||
)
|
||||
from frigate.util.path import sanitize_contained_path
|
||||
from frigate.util.recording_coverage import recording_overlap
|
||||
from frigate.util.time import is_current_hour
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -136,7 +134,6 @@ def _validate_export_case(export_case_id: str | None) -> JSONResponse | None:
|
||||
|
||||
|
||||
def _sanitize_existing_image(
|
||||
request: Request,
|
||||
image_path: str | None,
|
||||
) -> tuple[str | None, JSONResponse | None]:
|
||||
if not image_path:
|
||||
@@ -144,15 +141,6 @@ def _sanitize_existing_image(
|
||||
|
||||
existing_image = sanitize_contained_path(image_path, CLIPS_DIR)
|
||||
|
||||
# CLIPS_DIR is shared by every camera, so the caller must also be allowed
|
||||
# to read the image.
|
||||
if existing_image is not None and deny_response_for_media_uri(
|
||||
f"/clips/{quote(os.path.relpath(existing_image, CLIPS_DIR))}",
|
||||
request.headers.get("remote-role"),
|
||||
request.app.frigate_config,
|
||||
):
|
||||
existing_image = None
|
||||
|
||||
if existing_image is None:
|
||||
return None, JSONResponse(
|
||||
content={"success": False, "message": "Invalid image path"},
|
||||
@@ -178,7 +166,15 @@ def _validate_export_source(
|
||||
) -> str | None:
|
||||
if playback_source == PlaybackSourceEnum.recordings:
|
||||
query = Recordings.select().where(
|
||||
recording_overlap(start_time, end_time) & (Recordings.camera == camera_name)
|
||||
(
|
||||
Recordings.start_time.between(start_time, end_time)
|
||||
| Recordings.end_time.between(start_time, end_time)
|
||||
| (
|
||||
(start_time > Recordings.start_time)
|
||||
& (end_time < Recordings.end_time)
|
||||
)
|
||||
)
|
||||
& (Recordings.camera == camera_name)
|
||||
)
|
||||
|
||||
# a pinned export reads only that stream, so the other stream's
|
||||
@@ -243,7 +239,9 @@ def _get_item_recording_export_errors(
|
||||
|
||||
query = Recordings.select(Recordings.start_time, Recordings.end_time).where(
|
||||
Recordings.camera == camera_name,
|
||||
recording_overlap(min_start, max_end),
|
||||
Recordings.start_time.between(min_start, max_end)
|
||||
| Recordings.end_time.between(min_start, max_end)
|
||||
| ((min_start > Recordings.start_time) & (max_end < Recordings.end_time)),
|
||||
)
|
||||
|
||||
# a pinned batch reads only that stream, so the other stream's
|
||||
@@ -703,7 +701,7 @@ def export_recordings_batch(
|
||||
sanitized_images: list[str | None] = []
|
||||
for item in body.items:
|
||||
existing_image, image_validation_error = _sanitize_existing_image(
|
||||
request, item.image_path
|
||||
item.image_path
|
||||
)
|
||||
if image_validation_error is not None:
|
||||
return image_validation_error
|
||||
@@ -851,9 +849,7 @@ def export_recording(
|
||||
|
||||
playback_source = body.source
|
||||
friendly_name = body.name
|
||||
existing_image, image_validation_error = _sanitize_existing_image(
|
||||
request, body.image_path
|
||||
)
|
||||
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
||||
if image_validation_error is not None:
|
||||
return image_validation_error
|
||||
|
||||
@@ -1048,9 +1044,7 @@ def export_recording_custom(
|
||||
|
||||
playback_source = body.source
|
||||
friendly_name = body.name
|
||||
existing_image, image_validation_error = _sanitize_existing_image(
|
||||
request, body.image_path
|
||||
)
|
||||
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
||||
if image_validation_error is not None:
|
||||
return image_validation_error
|
||||
ffmpeg_input_args = body.ffmpeg_input_args
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -82,20 +81,9 @@ def create_fastapi_app(
|
||||
config_holder: ConfigHolder | None = None,
|
||||
notice_registry: NoticeRegistry | None = None,
|
||||
):
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("FastAPI started")
|
||||
asyncio.create_task(
|
||||
debug_replay_auto_stop_watchdog(
|
||||
replay_manager, frigate_config, config_publisher
|
||||
)
|
||||
)
|
||||
yield
|
||||
|
||||
logger.info("Starting FastAPI app")
|
||||
app = FastAPI(
|
||||
debug=False,
|
||||
lifespan=lifespan,
|
||||
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
|
||||
dependencies=[Depends(require_admin_by_default())]
|
||||
if enforce_default_admin
|
||||
@@ -131,6 +119,15 @@ def create_fastapi_app(
|
||||
database.close()
|
||||
return response
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
logger.info("FastAPI started")
|
||||
asyncio.create_task(
|
||||
debug_replay_auto_stop_watchdog(
|
||||
replay_manager, frigate_config, config_publisher
|
||||
)
|
||||
)
|
||||
|
||||
# Rate limiter (used for login endpoint)
|
||||
if frigate_config.auth.failed_login_rate_limit is None:
|
||||
limiter.enabled = False
|
||||
@@ -196,9 +193,7 @@ def create_fastapi_app(
|
||||
key_bytes = str(secret).encode("utf-8")
|
||||
|
||||
app.jwt_token = OctKey.import_key(key_bytes)
|
||||
app.user_sessions = auth.load_user_sessions()
|
||||
else:
|
||||
app.jwt_token = None
|
||||
app.user_sessions = {}
|
||||
|
||||
return app
|
||||
|
||||
+33
-33
@@ -14,10 +14,10 @@ from enum import Enum
|
||||
from pathlib import Path as FilePath
|
||||
from typing import IO, Any
|
||||
from urllib.parse import unquote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytz
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from pathvalidate import sanitize_filename
|
||||
@@ -62,10 +62,9 @@ from frigate.util.recording_coverage import (
|
||||
build_spans,
|
||||
null_audio_glitches,
|
||||
plan_clip,
|
||||
recording_overlap,
|
||||
resolve_coverage,
|
||||
stream_has_audio,
|
||||
)
|
||||
from frigate.util.time import get_normalized_tz_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -297,15 +296,10 @@ async def latest_frame(
|
||||
and request.app.frigate_config.birdseye.enabled
|
||||
and request.app.frigate_config.birdseye.restream
|
||||
):
|
||||
frame = frame_processor.get_current_frame(camera_name)
|
||||
|
||||
if frame is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unable to get valid frame"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||
frame = cv2.cvtColor(
|
||||
frame_processor.get_current_frame(camera_name),
|
||||
cv2.COLOR_YUV2BGR_I420,
|
||||
)
|
||||
|
||||
height = int(params.height or str(frame.shape[0]))
|
||||
width = int(height * frame.shape[1] / frame.shape[0])
|
||||
@@ -353,7 +347,10 @@ async def get_snapshot_from_recording(
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
)
|
||||
.where(recording_overlap(frame_time, frame_time))
|
||||
.where(
|
||||
(frame_time >= Recordings.start_time)
|
||||
& (frame_time <= Recordings.end_time)
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
|
||||
.limit(1)
|
||||
@@ -369,7 +366,10 @@ async def get_snapshot_from_recording(
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
)
|
||||
.where(recording_overlap(frame_time, frame_time))
|
||||
.where(
|
||||
(frame_time >= Recordings.start_time)
|
||||
& (frame_time <= Recordings.end_time)
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
|
||||
.limit(1)
|
||||
@@ -384,13 +384,8 @@ async def get_snapshot_from_recording(
|
||||
mime_type = "png" if format == "png" else "jpeg"
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
image_data = await asyncio.to_thread(
|
||||
get_image_from_recording,
|
||||
config.ffmpeg,
|
||||
recording.path,
|
||||
time_in_segment,
|
||||
codec,
|
||||
height,
|
||||
image_data = get_image_from_recording(
|
||||
config.ffmpeg, recording.path, time_in_segment, codec, height
|
||||
)
|
||||
|
||||
if not image_data:
|
||||
@@ -432,7 +427,9 @@ async def submit_recording_snapshot_to_plus(
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
)
|
||||
.where(recording_overlap(frame_time, frame_time))
|
||||
.where(
|
||||
(frame_time >= Recordings.start_time) & (frame_time <= Recordings.end_time)
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
|
||||
.limit(1)
|
||||
@@ -442,12 +439,8 @@ async def submit_recording_snapshot_to_plus(
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
recording: Recordings = recording_query.get()
|
||||
time_in_segment = frame_time - recording.start_time
|
||||
image_data = await asyncio.to_thread(
|
||||
get_image_from_recording,
|
||||
config.ffmpeg,
|
||||
recording.path,
|
||||
time_in_segment,
|
||||
"png",
|
||||
image_data = get_image_from_recording(
|
||||
config.ffmpeg, recording.path, time_in_segment, "png"
|
||||
)
|
||||
|
||||
if not image_data:
|
||||
@@ -546,7 +539,11 @@ async def recording_clip(
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(recording_overlap(start_ts, end_ts))
|
||||
.where(
|
||||
(Recordings.start_time.between(start_ts, end_ts))
|
||||
| (Recordings.end_time.between(start_ts, end_ts))
|
||||
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.where(Recordings.stream_type == stream_type)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
@@ -684,10 +681,15 @@ async def _vod_response(
|
||||
end_ts,
|
||||
force_discontinuity,
|
||||
)
|
||||
intervals = resolve_coverage(camera_name, start_ts, end_ts)
|
||||
|
||||
# rows contradicting their stream's audio composition are
|
||||
# truncated-shutdown glitches
|
||||
main_audio = stream_has_audio(intervals, main=True)
|
||||
sub_audio = stream_has_audio(intervals, main=False)
|
||||
|
||||
spans = build_spans(
|
||||
null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
|
||||
null_audio_glitches(intervals, main_audio, sub_audio),
|
||||
stream_preference,
|
||||
)
|
||||
|
||||
@@ -841,9 +843,7 @@ async def vod_hour(
|
||||
parts = year_month.split("-")
|
||||
start_date = (
|
||||
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC)
|
||||
- datetime.now(
|
||||
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
|
||||
).utcoffset()
|
||||
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
|
||||
)
|
||||
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
|
||||
start_ts = start_date.timestamp()
|
||||
|
||||
+10
-42
@@ -13,9 +13,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from enum import Enum
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from peewee import DoesNotExist
|
||||
|
||||
@@ -25,8 +24,6 @@ from frigate.models import Export, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_URI_SCHEME_AND_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://[^/?]*")
|
||||
|
||||
|
||||
class MediaAuthResolution(str, Enum):
|
||||
"""Classification of an `X-Original-URL` path for media-auth purposes."""
|
||||
@@ -44,36 +41,6 @@ class MediaAuthResolution(str, Enum):
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
def split_request_uri(request_uri: str) -> tuple[str, str]:
|
||||
"""Split a raw request URI into its path and query string.
|
||||
|
||||
urlparse is not used since it reads a path starting with `//` as a host.
|
||||
nginx ends the URI at a literal `#`, so anything after it is dropped.
|
||||
"""
|
||||
uri = _URI_SCHEME_AND_AUTHORITY.sub("", request_uri, count=1)
|
||||
path, _, query = uri.partition("#")[0].partition("?")
|
||||
return path, query
|
||||
|
||||
|
||||
def normalize_request_path(path: str) -> str:
|
||||
"""Normalize a raw request path the way nginx does before matching locations.
|
||||
|
||||
nginx percent-decodes the path, merges repeated slashes and resolves dot
|
||||
segments, while `X-Original-URL` carries the raw `$request_uri`.
|
||||
"""
|
||||
segments: list[str] = []
|
||||
for segment in unquote(path).split("/"):
|
||||
if segment in ("", "."):
|
||||
continue
|
||||
if segment == "..":
|
||||
if segments:
|
||||
segments.pop()
|
||||
continue
|
||||
segments.append(segment)
|
||||
|
||||
return "/" + "/".join(segments)
|
||||
|
||||
|
||||
def extract_path(original_url: str | None) -> str | None:
|
||||
"""Return the decoded path component of nginx's `X-Original-URL` header.
|
||||
|
||||
@@ -82,14 +49,14 @@ def extract_path(original_url: str | None) -> str | None:
|
||||
request like `/recordings/.../allowed_cam/../forbidden_cam/file.mp4`
|
||||
would (1) parse as the allowed camera in our auth check, (2) be served
|
||||
as the forbidden camera by nginx. To close the bypass we reject any URI
|
||||
whose path contains `.` or `..` segments outright. Repeated slashes are
|
||||
merged, as nginx does before matching locations.
|
||||
whose path contains `.` or `..` segments outright.
|
||||
"""
|
||||
if not original_url:
|
||||
return None
|
||||
|
||||
path, _ = split_request_uri(original_url)
|
||||
decoded = re.sub(r"/{2,}", "/", unquote(path))
|
||||
parsed = urlparse(original_url)
|
||||
raw_path = parsed.path or original_url
|
||||
decoded = unquote(raw_path)
|
||||
if not decoded:
|
||||
return None
|
||||
|
||||
@@ -285,11 +252,12 @@ def deny_response_for_media_uri(
|
||||
path = extract_path(original_url)
|
||||
|
||||
# `extract_path` returns None for URIs containing `.` or `..` segments.
|
||||
# Deny them when nginx would resolve them under a media root, and pass
|
||||
# the rest through to nginx and the backend.
|
||||
# For media-root URIs that's a traversal attempt — deny outright. For
|
||||
# non-media URIs, pass through (nginx / the backend handle them).
|
||||
if path is None:
|
||||
normalized = normalize_request_path(split_request_uri(original_url)[0])
|
||||
first = normalized.lstrip("/").split("/", 1)[0]
|
||||
raw = urlparse(original_url).path or original_url
|
||||
decoded = unquote(raw)
|
||||
first = decoded.lstrip("/").split("/", 1)[0] if decoded else ""
|
||||
if first in ("clips", "recordings", "exports"):
|
||||
return 403
|
||||
return None
|
||||
|
||||
@@ -9,10 +9,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.api.auth import require_camera_access
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.jobs.motion_search import (
|
||||
MotionSearchCapacityError,
|
||||
MotionSearchUserBusyError,
|
||||
cancel_motion_search_job,
|
||||
get_motion_search_job,
|
||||
start_motion_search_job,
|
||||
@@ -98,20 +95,6 @@ class MotionSearchStatusResponse(BaseModel):
|
||||
progress: float | None = None
|
||||
|
||||
|
||||
def _per_user_identity(request: Request, config: FrigateConfig) -> str | None:
|
||||
"""Return the requesting user, or None when callers share one identity.
|
||||
|
||||
Internal-port requests are all "anonymous", and with auth disabled and no
|
||||
proxy user header every browser is "viewer".
|
||||
"""
|
||||
username = request.headers.get("remote-user")
|
||||
if not username or username == "anonymous":
|
||||
return None
|
||||
if config.auth.enabled or config.proxy.header_map.user:
|
||||
return username
|
||||
return None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{camera_name}/search/motion",
|
||||
response_model=MotionSearchStartResponse,
|
||||
@@ -155,35 +138,18 @@ async def start_motion_search(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
job_id = start_motion_search_job(
|
||||
config=config,
|
||||
camera_name=camera_name,
|
||||
start_time=body.start_time,
|
||||
end_time=body.end_time,
|
||||
polygon_points=body.polygon_points,
|
||||
threshold=body.threshold,
|
||||
min_area=body.min_area,
|
||||
parallel=body.parallel,
|
||||
max_results=body.max_results,
|
||||
username=_per_user_identity(request, config),
|
||||
)
|
||||
except MotionSearchUserBusyError:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "A motion search is already running for this user",
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
except MotionSearchCapacityError:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Too many motion searches are running",
|
||||
},
|
||||
status_code=429,
|
||||
)
|
||||
# Start the job using the jobs module
|
||||
job_id = start_motion_search_job(
|
||||
config=config,
|
||||
camera_name=camera_name,
|
||||
start_time=body.start_time,
|
||||
end_time=body.end_time,
|
||||
polygon_points=body.polygon_points,
|
||||
threshold=body.threshold,
|
||||
min_area=body.min_area,
|
||||
parallel=body.parallel,
|
||||
max_results=body.max_results,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
|
||||
+22
-50
@@ -14,18 +14,17 @@ router = APIRouter(tags=[Tags.notices])
|
||||
|
||||
|
||||
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_notices(request: Request, include_hidden: bool = False) -> JSONResponse:
|
||||
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse:
|
||||
"""Get notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_hidden: Also return acknowledged and muted notices, for the
|
||||
hidden list
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
|
||||
Returns:
|
||||
The notices
|
||||
"""
|
||||
return JSONResponse(
|
||||
content=request.app.notice_registry.active(include_hidden=include_hidden)
|
||||
content=request.app.notice_registry.active(include_dismissed=include_dismissed)
|
||||
)
|
||||
|
||||
|
||||
@@ -35,64 +34,37 @@ def get_notice_stats(request: Request) -> JSONResponse:
|
||||
return JSONResponse(content=request.app.notice_registry.stats())
|
||||
|
||||
|
||||
@router.get("/notices/muted_checks", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_muted_checks(request: Request) -> JSONResponse:
|
||||
"""Get the muted config and stream check rows, newest first."""
|
||||
return JSONResponse(content=request.app.notice_registry.muted_checks())
|
||||
@router.get(
|
||||
"/notices/dismissed_checks", dependencies=[Depends(require_role(["admin"]))]
|
||||
)
|
||||
def get_dismissed_checks(request: Request) -> JSONResponse:
|
||||
"""Get the dismissed config and stream check rows, newest first."""
|
||||
return JSONResponse(content=request.app.notice_registry.dismissed_checks())
|
||||
|
||||
|
||||
@router.delete("/notices/hidden", dependencies=[Depends(require_role(["admin"]))])
|
||||
def unhide_all_notices(request: Request) -> JSONResponse:
|
||||
"""Show every acknowledged and muted notice and check row again."""
|
||||
request.app.notice_registry.unhide_all()
|
||||
return JSONResponse(content={"success": True, "message": "Notices shown again"})
|
||||
@router.delete("/notices/dismissed", dependencies=[Depends(require_role(["admin"]))])
|
||||
def purge_dismissed(request: Request) -> JSONResponse:
|
||||
"""Delete every dismissed notice and check row so each can show again."""
|
||||
request.app.notice_registry.purge_dismissed()
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Dismissed notices cleared"}
|
||||
)
|
||||
|
||||
|
||||
# model notice ids contain a slash, so the id is a path parameter
|
||||
@router.post(
|
||||
"/notices/{notice_id:path}/acknowledge",
|
||||
"/notices/{notice_id:path}/dismiss",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def acknowledge_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice until it happens again.
|
||||
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice or a config or stream check row.
|
||||
|
||||
Config and stream check rows and the update notice never repeat, so they
|
||||
can only be muted.
|
||||
It stays hidden if the same problem happens again.
|
||||
"""
|
||||
if not request.app.notice_registry.acknowledge(notice_id):
|
||||
if not request.app.notice_registry.dismiss(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice acknowledged"})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notices/{notice_id:path}/mute",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def mute_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice or a config or stream check row for good."""
|
||||
if not request.app.notice_registry.mute(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice muted"})
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/notices/{notice_id:path}/hidden",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def unhide_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Show an acknowledged or muted notice or check row again."""
|
||||
if not request.app.notice_registry.unhide(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice shown again"})
|
||||
return JSONResponse(content={"success": True, "message": "Notice dismissed"})
|
||||
|
||||
+75
-16
@@ -1,17 +1,19 @@
|
||||
"""Notification apis."""
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from peewee import DoesNotExist
|
||||
from py_vapid import Vapid01, utils
|
||||
|
||||
from frigate.api.auth import allow_any_authenticated
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.comms.webpush import validate_push_endpoint
|
||||
from frigate.const import CONFIG_DIR
|
||||
from frigate.models import User
|
||||
|
||||
@@ -19,13 +21,76 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.notifications])
|
||||
|
||||
# Push endpoints are opaque URLs but stay well under this in practice
|
||||
MAX_ENDPOINT_LENGTH = 2048
|
||||
|
||||
# Suffixes that only ever resolve on the local network
|
||||
INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa")
|
||||
|
||||
|
||||
def _validate_push_endpoint(endpoint: Any) -> str | None:
|
||||
"""Return a reason the endpoint is unusable, or None when it is valid.
|
||||
|
||||
Subscriptions are issued by the browser vendor's push service, so a valid
|
||||
endpoint is always a public https URL. Anything else is either a broken
|
||||
registration or an attempt to aim the notification sender somewhere it
|
||||
should not reach.
|
||||
"""
|
||||
if not isinstance(endpoint, str) or not endpoint:
|
||||
return "endpoint must be a url"
|
||||
|
||||
if len(endpoint) > MAX_ENDPOINT_LENGTH:
|
||||
return "endpoint is too long"
|
||||
|
||||
try:
|
||||
parsed = urlparse(endpoint)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return "endpoint is not a valid url"
|
||||
|
||||
if parsed.scheme != "https":
|
||||
return "endpoint must use https"
|
||||
|
||||
if parsed.username or parsed.password:
|
||||
return "endpoint must not include credentials"
|
||||
|
||||
if port is not None and port != 443:
|
||||
return "endpoint must use the default https port"
|
||||
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
return "endpoint must include a hostname"
|
||||
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
address = None
|
||||
|
||||
if address is not None:
|
||||
# A push service is never reachable at an address only this network can
|
||||
# route, so anything non-global is a misconfiguration at best
|
||||
if not address.is_global:
|
||||
return "endpoint must not use a private address"
|
||||
elif hostname == "localhost" or "." not in hostname:
|
||||
return "endpoint must use a fully qualified hostname"
|
||||
elif hostname.endswith(INTERNAL_HOST_SUFFIXES):
|
||||
return "endpoint must not use an internal hostname"
|
||||
|
||||
# The subscription token lives in the path, and webpush.py assumes there is
|
||||
# a separator after the host when it builds the VAPID audience
|
||||
if len(parsed.path) <= 1:
|
||||
return "endpoint must include a subscription path"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_subscription(sub: Any) -> str | None:
|
||||
"""Return a reason the subscription is unusable, or None when it is valid."""
|
||||
if not isinstance(sub, dict):
|
||||
return "subscription must be an object"
|
||||
|
||||
reason = validate_push_endpoint(sub.get("endpoint"))
|
||||
reason = _validate_push_endpoint(sub.get("endpoint"))
|
||||
|
||||
if reason:
|
||||
return reason
|
||||
@@ -108,22 +173,16 @@ def register_notifications(request: Request, body: dict = None):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
updated = (
|
||||
User.update(notification_tokens=User.notification_tokens.append(sub))
|
||||
.where(User.username == username)
|
||||
.execute()
|
||||
)
|
||||
|
||||
if not updated:
|
||||
logger.warning(
|
||||
"Rejected notification registration for unknown user %s", username
|
||||
try:
|
||||
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
|
||||
User.username == username
|
||||
).execute()
|
||||
return JSONResponse(
|
||||
content=({"success": True, "message": "Successfully saved token."}),
|
||||
status_code=200,
|
||||
)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Could not find user."}),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content=({"success": True, "message": "Successfully saved token."}),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -22,7 +22,6 @@ from frigate.api.defs.response.preview_response import (
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
|
||||
from frigate.models import Previews
|
||||
from frigate.util.time import get_normalized_tz_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -127,9 +126,7 @@ def preview_hour(
|
||||
parts = year_month.split("-")
|
||||
start_date = (
|
||||
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC)
|
||||
- datetime.now(
|
||||
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
|
||||
).utcoffset()
|
||||
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
|
||||
)
|
||||
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
|
||||
start_ts = start_date.timestamp()
|
||||
|
||||
@@ -36,7 +36,6 @@ from frigate.util.recording_coverage import (
|
||||
coverage_spans,
|
||||
known_video_codecs,
|
||||
realized_timelines,
|
||||
recording_overlap,
|
||||
resolve_coverage,
|
||||
stream_media_summary,
|
||||
)
|
||||
@@ -176,7 +175,7 @@ async def all_recordings_summary(
|
||||
@router.get(
|
||||
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
|
||||
)
|
||||
async def recordings_summary(camera_name: str, timezone: str = "UTC"):
|
||||
async def recordings_summary(camera_name: str, timezone: str = "utc"):
|
||||
"""Returns hourly summary for recordings of given camera"""
|
||||
|
||||
time_range_query = (
|
||||
@@ -538,7 +537,11 @@ async def delete_recordings(
|
||||
|
||||
# Build query to find overlapping recordings
|
||||
clauses = [
|
||||
recording_overlap(start, end),
|
||||
(
|
||||
Recordings.start_time.between(start, end)
|
||||
| Recordings.end_time.between(start, end)
|
||||
| ((start > Recordings.start_time) & (end < Recordings.end_time))
|
||||
),
|
||||
(Recordings.camera << camera_list),
|
||||
]
|
||||
|
||||
|
||||
+20
-23
@@ -37,7 +37,6 @@ from frigate.const import STREAM_TYPE_MAIN
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
from frigate.models import Recordings, ReviewSegment, UserReviewStatus
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.util.recording_coverage import recording_overlap
|
||||
from frigate.util.time import get_dst_transitions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,22 +44,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.review])
|
||||
|
||||
|
||||
def get_label_clause(label: str, include_audio: bool = True):
|
||||
"""Build a clause matching a label within a review segment's data.
|
||||
|
||||
Verified objects are stored with a `-verified` suffix (eg. `person-verified`)
|
||||
so that variant is matched as well.
|
||||
"""
|
||||
clause = (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') | (
|
||||
ReviewSegment.data["objects"].cast("text") % f'*"{label}-verified"*'
|
||||
)
|
||||
|
||||
if include_audio:
|
||||
clause |= ReviewSegment.data["audio"].cast("text") % f'*"{label}"*'
|
||||
|
||||
return clause
|
||||
|
||||
|
||||
@router.get(
|
||||
"/review",
|
||||
response_model=list[ReviewSegmentResponse],
|
||||
@@ -110,7 +93,10 @@ async def review(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(get_label_clause(label))
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
|
||||
if zones != "all":
|
||||
@@ -253,7 +239,10 @@ async def review_summary(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(get_label_clause(label))
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
if zones != "all":
|
||||
# use matching so segments with multiple zones
|
||||
@@ -351,8 +340,9 @@ async def review_summary(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(get_label_clause(label, include_audio=False))
|
||||
|
||||
label_clauses.append(
|
||||
ReviewSegment.data["objects"].cast("text") % f'*"{label}"*'
|
||||
)
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
|
||||
# Find the time range of available data
|
||||
@@ -586,7 +576,14 @@ def delete_reviews(body: ReviewModifyMultipleBody):
|
||||
camera_name = review["camera"]
|
||||
recordings = (
|
||||
Recordings.select(Recordings.id, Recordings.path)
|
||||
.where(recording_overlap(start_time, end_time))
|
||||
.where(
|
||||
Recordings.start_time.between(start_time, end_time)
|
||||
| Recordings.end_time.between(start_time, end_time)
|
||||
| (
|
||||
(start_time > Recordings.start_time)
|
||||
& (end_time < Recordings.end_time)
|
||||
)
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.dicts()
|
||||
.iterator()
|
||||
@@ -696,7 +693,7 @@ def motion_activity(
|
||||
df = df[df["camera"] != ""]
|
||||
|
||||
# change types for output
|
||||
df.index = df.index.as_unit("s").astype(int)
|
||||
df.index = df.index.astype(int) // (10**9)
|
||||
normalized = df.reset_index().to_dict("records")
|
||||
return JSONResponse(content=normalized)
|
||||
|
||||
|
||||
+14
-31
@@ -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
|
||||
@@ -49,6 +49,7 @@ from frigate.debug_replay import (
|
||||
DebugReplayManager,
|
||||
cleanup_replay_cameras,
|
||||
)
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.detectors.detector_types import api_types
|
||||
from frigate.detectors.device import build_detector_config, runner_names
|
||||
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
|
||||
@@ -77,7 +78,7 @@ from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.output.output import OutputProcess
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
from frigate.record.cleanup import RecordingCleanup
|
||||
from frigate.record.export import migrate_exports
|
||||
@@ -107,7 +108,7 @@ class FrigateApp:
|
||||
self.metrics_manager = manager
|
||||
self.audio_process: mp.Process | None = None
|
||||
self.stop_event = stop_event
|
||||
self.detection_queues: dict[str, Queue] = {
|
||||
self.detection_queues: dict[SceneEnum, Queue] = {
|
||||
model.scene: mp.Queue() for model in config.models
|
||||
}
|
||||
self.detectors: dict[str, ObjectDetectProcess] = {}
|
||||
@@ -166,7 +167,11 @@ class FrigateApp:
|
||||
# create camera_metrics
|
||||
for camera_name in self.config.cameras.keys():
|
||||
self.camera_metrics[camera_name] = CameraMetrics(self.metrics_manager)
|
||||
self.ptz_metrics[camera_name] = PTZMetrics()
|
||||
self.ptz_metrics[camera_name] = PTZMetrics(
|
||||
autotracker_enabled=self.config.cameras[
|
||||
camera_name
|
||||
].onvif.autotracking.enabled
|
||||
)
|
||||
|
||||
def init_queues(self) -> None:
|
||||
# Queue for cameras to push tracked objects to
|
||||
@@ -186,7 +191,7 @@ class FrigateApp:
|
||||
self.timeline_queue: Queue = mp.Queue()
|
||||
|
||||
def init_database(self) -> None:
|
||||
def vacuum_db(db: SqliteDatabase) -> None:
|
||||
def vacuum_db(db: SqliteExtDatabase) -> None:
|
||||
logger.info("Running database vacuum")
|
||||
db.execute_sql("VACUUM;")
|
||||
|
||||
@@ -197,7 +202,7 @@ class FrigateApp:
|
||||
logger.error("Unable to write to /config to save DB state")
|
||||
|
||||
# Migrate DB schema
|
||||
migrate_db = SqliteDatabase(self.config.database.path)
|
||||
migrate_db = SqliteExtDatabase(self.config.database.path)
|
||||
|
||||
# Run migrations
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
@@ -390,7 +395,7 @@ class FrigateApp:
|
||||
logger.error("Unable to prepare the %s runtime: %s", detector_type, err)
|
||||
|
||||
def start_detectors(self) -> None:
|
||||
model_cameras: dict[str, list[str]] = {
|
||||
model_cameras: dict[SceneEnum, list[str]] = {
|
||||
model.scene: [] for model in self.config.models
|
||||
}
|
||||
|
||||
@@ -439,7 +444,7 @@ class FrigateApp:
|
||||
)
|
||||
|
||||
def start_ptz_autotracker(self) -> None:
|
||||
self.ptz_autotracker_thread = PtzAutoTracker(
|
||||
self.ptz_autotracker_thread = PtzAutoTrackerThread(
|
||||
self.config,
|
||||
self.onvif_controller,
|
||||
self.ptz_metrics,
|
||||
@@ -581,12 +586,7 @@ class FrigateApp:
|
||||
|
||||
def init_auth(self) -> None:
|
||||
if self.config.auth.enabled:
|
||||
if (
|
||||
User.select().count() == 0
|
||||
or User.select()
|
||||
.where((User.username == "admin") & (User.password_hash == ""))
|
||||
.exists()
|
||||
):
|
||||
if User.select().count() == 0:
|
||||
password = secrets.token_hex(16)
|
||||
password_hash = hash_password(
|
||||
password, iterations=self.config.auth.hash_iterations
|
||||
@@ -598,9 +598,6 @@ class FrigateApp:
|
||||
User.password_hash: password_hash,
|
||||
User.notification_tokens: [],
|
||||
}
|
||||
).on_conflict(
|
||||
conflict_target=[User.username],
|
||||
update={User.password_hash: password_hash},
|
||||
).execute()
|
||||
|
||||
self.config.auth.admin_first_time_login = True
|
||||
@@ -622,7 +619,6 @@ class FrigateApp:
|
||||
username="admin",
|
||||
role="admin",
|
||||
password_hash=password_hash,
|
||||
password_changed_at=datetime.datetime.now(),
|
||||
notification_tokens=[],
|
||||
).execute()
|
||||
|
||||
@@ -632,19 +628,6 @@ class FrigateApp:
|
||||
logger.info(f"*** Password: {password} ***")
|
||||
logger.info("********************************************************")
|
||||
logger.info("********************************************************")
|
||||
else:
|
||||
# Notification registrations store tokens on the admin row. Its
|
||||
# empty password hash marks it for a generated password if auth
|
||||
# is enabled later.
|
||||
if User.select().count() == 0:
|
||||
User.insert(
|
||||
{
|
||||
User.username: "admin",
|
||||
User.role: "admin",
|
||||
User.password_hash: "",
|
||||
User.notification_tokens: [],
|
||||
}
|
||||
).execute()
|
||||
|
||||
def start(self) -> None:
|
||||
logger.info(f"Starting Frigate ({VERSION})")
|
||||
|
||||
@@ -43,6 +43,8 @@ class CameraMetrics:
|
||||
|
||||
|
||||
class PTZMetrics:
|
||||
autotracker_enabled: Synchronized
|
||||
|
||||
start_time: Synchronized
|
||||
stop_time: Synchronized
|
||||
frame_time: Synchronized
|
||||
@@ -50,17 +52,21 @@ class PTZMetrics:
|
||||
max_zoom: Synchronized
|
||||
min_zoom: Synchronized
|
||||
|
||||
tracking_active: Event
|
||||
motor_stopped: Event
|
||||
reset: Event
|
||||
|
||||
def __init__(self) -> None:
|
||||
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)
|
||||
def __init__(self, *, autotracker_enabled: bool):
|
||||
self.autotracker_enabled = mp.Value("i", autotracker_enabled) # type: ignore[assignment]
|
||||
|
||||
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.tracking_active = mp.Event()
|
||||
self.motor_stopped = mp.Event()
|
||||
self.reset = mp.Event()
|
||||
|
||||
|
||||
@@ -104,13 +104,12 @@ class CameraActivityManager:
|
||||
all_objects: list[dict[str, Any]] = []
|
||||
|
||||
for camera in new_activity.keys():
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.camera_all_object_counts:
|
||||
self.__init_camera(camera_config)
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
|
||||
new_objects = new_activity[camera].get("objects", [])
|
||||
all_objects.extend(new_objects)
|
||||
@@ -235,13 +234,12 @@ class AudioActivityManager:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
for camera in new_activity.keys():
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.current_audio_detections:
|
||||
self.__init_camera(camera_config)
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
|
||||
new_detections = new_activity[camera].get("detections", [])
|
||||
if self.compare_audio_activity(camera, new_detections, now):
|
||||
|
||||
@@ -15,6 +15,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.models import Regions
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.util.builtin import empty_and_close_queue
|
||||
@@ -30,7 +31,7 @@ class CameraMaintainer(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
detection_queues: dict[str, Queue],
|
||||
detection_queues: dict[SceneEnum, Queue],
|
||||
detected_frames_queue: Queue,
|
||||
camera_metrics: DictProxy,
|
||||
ptz_metrics: dict[str, PTZMetrics],
|
||||
@@ -120,7 +121,9 @@ class CameraMaintainer(threading.Thread):
|
||||
|
||||
if runtime:
|
||||
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
|
||||
self.ptz_metrics[name] = PTZMetrics()
|
||||
self.ptz_metrics[name] = PTZMetrics(
|
||||
autotracker_enabled=config.onvif.autotracking.enabled
|
||||
)
|
||||
self.region_grids[name] = get_camera_regions_grid(
|
||||
name,
|
||||
config.detect,
|
||||
@@ -169,10 +172,6 @@ class CameraMaintainer(threading.Thread):
|
||||
frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1]
|
||||
self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
|
||||
|
||||
# a leftover ledger from an unclean exit may be the wrong length
|
||||
self.frame_manager.delete(f"{config.name}_frame_times")
|
||||
self.frame_manager.create(f"{config.name}_frame_times", max(count, 1) * 8)
|
||||
|
||||
capture_process = CameraCapture(
|
||||
config,
|
||||
count,
|
||||
|
||||
+22
-14
@@ -16,7 +16,7 @@ from frigate.config import (
|
||||
ZoomingModeEnum,
|
||||
)
|
||||
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
||||
from frigate.ptz.autotrack import PtzAutoTrackerThread
|
||||
from frigate.track.tracked_object import TrackedObject
|
||||
from frigate.util.image import (
|
||||
SharedMemoryFrameManager,
|
||||
@@ -35,7 +35,7 @@ class CameraState:
|
||||
name: str,
|
||||
config: FrigateConfig,
|
||||
frame_manager: SharedMemoryFrameManager,
|
||||
ptz_autotracker_thread: PtzAutoTracker,
|
||||
ptz_autotracker_thread: PtzAutoTrackerThread,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.config = config
|
||||
@@ -115,13 +115,17 @@ class CameraState:
|
||||
# draw thicker box around ptz autotracked object
|
||||
if (
|
||||
self.camera_config.onvif.autotracking.enabled
|
||||
and self.ptz_autotracker_thread.autotracker_init.get(self.name)
|
||||
and self.ptz_autotracker_thread.tracked_object[self.name]
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get(
|
||||
self.name
|
||||
)
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
]
|
||||
is not None
|
||||
and obj["id"]
|
||||
== self.ptz_autotracker_thread.tracked_object[ # type: ignore[union-attr]
|
||||
== self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
].obj_data["id"]
|
||||
].obj_data["id"] # type: ignore[attr-defined]
|
||||
and obj["frame_time"] == frame_time
|
||||
):
|
||||
thickness = 5
|
||||
@@ -134,9 +138,9 @@ class CameraState:
|
||||
and self.camera_config.detect.width is not None
|
||||
and self.camera_config.detect.height is not None
|
||||
):
|
||||
max_target_box = calculate_max_target_box(
|
||||
self.camera_config.onvif.autotracking.zoom_factor
|
||||
)
|
||||
max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[
|
||||
self.name
|
||||
]["max_target_box"] # type: ignore[index]
|
||||
side_length = max_target_box * (
|
||||
max(
|
||||
self.camera_config.detect.width,
|
||||
@@ -349,7 +353,7 @@ class CameraState:
|
||||
return
|
||||
|
||||
current_frame = self.frame_manager.get(
|
||||
frame_name, self.camera_config.frame_shape_yuv, frame_time
|
||||
frame_name, self.camera_config.frame_shape_yuv
|
||||
)
|
||||
|
||||
tracked_objects = self.tracked_objects.copy()
|
||||
@@ -392,6 +396,7 @@ class CameraState:
|
||||
"attributes": new_obj.obj_data["attributes"],
|
||||
"current_estimated_speed": 0,
|
||||
"velocity_angle": 0,
|
||||
"path_data": [],
|
||||
"recognized_license_plate": None,
|
||||
"recognized_license_plate_score": None,
|
||||
}
|
||||
@@ -403,7 +408,7 @@ class CameraState:
|
||||
self.send_mqtt_snapshot(new_obj, object_type)
|
||||
|
||||
for c in self.callbacks["start"]:
|
||||
c(self.name, new_obj, frame_name, frame_time)
|
||||
c(self.name, new_obj, frame_name)
|
||||
|
||||
for id in updated_ids:
|
||||
updated_obj = tracked_objects[id]
|
||||
@@ -415,7 +420,7 @@ class CameraState:
|
||||
|
||||
if autotracker_update or significant_update:
|
||||
for c in self.callbacks["autotrack"]:
|
||||
c(self.name, updated_obj, frame_name, frame_time)
|
||||
c(self.name, updated_obj, frame_name)
|
||||
|
||||
if thumb_update and current_frame is not None:
|
||||
# ensure this frame is stored in the cache
|
||||
@@ -465,7 +470,7 @@ class CameraState:
|
||||
):
|
||||
# call event handlers
|
||||
for c in self.callbacks["update"]:
|
||||
c(self.name, updated_obj, frame_name, frame_time)
|
||||
c(self.name, updated_obj, frame_name)
|
||||
updated_obj.last_published = frame_time
|
||||
|
||||
# send MQTT snapshot when object first enters a required zone,
|
||||
@@ -487,7 +492,7 @@ class CameraState:
|
||||
removed_obj.obj_data["end_time"] = frame_time
|
||||
logger.debug(f"{self.name}: end callback for object {id}")
|
||||
for c in self.callbacks["end"]:
|
||||
c(self.name, removed_obj, frame_name, frame_time)
|
||||
c(self.name, removed_obj, frame_name)
|
||||
|
||||
# TODO: can i switch to looking this up and only changing when an event ends?
|
||||
# maintain best objects
|
||||
@@ -602,6 +607,9 @@ class CameraState:
|
||||
self.current_frame_time = frame_time
|
||||
self._current_frame = np.copy(current_frame)
|
||||
|
||||
if self.previous_frame_id is not None:
|
||||
self.frame_manager.close(self.previous_frame_id)
|
||||
|
||||
self.previous_frame_id = frame_name
|
||||
|
||||
def send_mqtt_snapshot(self, new_obj: TrackedObject, object_type: str) -> None:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Facilitates communication between processes."""
|
||||
|
||||
import multiprocessing as mp
|
||||
import threading
|
||||
from _pickle import UnpicklingError
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
@@ -19,13 +18,11 @@ class ConfigPublisher:
|
||||
self.socket = self.context.socket(zmq.PUB)
|
||||
self.socket.bind(SOCKET_PUB_SUB)
|
||||
self.stop_event: MpEvent = mp.Event()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def publish(self, topic: str, payload: Any) -> None:
|
||||
"""There is no communication back to the processes."""
|
||||
with self.lock:
|
||||
self.socket.send_string(topic, flags=zmq.SNDMORE)
|
||||
self.socket.send_pyobj(payload)
|
||||
self.socket.send_string(topic, flags=zmq.SNDMORE)
|
||||
self.socket.send_pyobj(payload)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
|
||||
@@ -12,7 +12,6 @@ class DetectionTypeEnum(str, Enum):
|
||||
video = "video"
|
||||
audio = "audio"
|
||||
lpr = "lpr"
|
||||
classification_state = "classification_state"
|
||||
|
||||
|
||||
class DetectionPublisher(Publisher):
|
||||
|
||||
+10
-28
@@ -322,11 +322,7 @@ class Dispatcher:
|
||||
self.audio_activity.expire_all(payload)
|
||||
|
||||
def handle_update_event_description() -> None:
|
||||
event: Event | None = Event.get_or_none(Event.id == payload["id"])
|
||||
|
||||
if event is None:
|
||||
return
|
||||
|
||||
event: Event = Event.get(Event.id == payload["id"])
|
||||
cast(dict, event.data)["description"] = payload["description"]
|
||||
event.save()
|
||||
self.publish(
|
||||
@@ -490,12 +486,7 @@ class Dispatcher:
|
||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||
"""Handle publishing to communicators."""
|
||||
for comm in self.comms:
|
||||
try:
|
||||
comm.publish(topic, payload, retain)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error publishing %s to %s", topic, type(comm).__name__
|
||||
)
|
||||
comm.publish(topic, payload, retain)
|
||||
|
||||
def publish_local(self, topic: str, payload: Any) -> None:
|
||||
"""Publish to every communicator except MQTT.
|
||||
@@ -506,12 +497,7 @@ class Dispatcher:
|
||||
if isinstance(comm, MqttClient):
|
||||
continue
|
||||
|
||||
try:
|
||||
comm.publish(topic, payload, False)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error publishing %s to %s", topic, type(comm).__name__
|
||||
)
|
||||
comm.publish(topic, payload, False)
|
||||
|
||||
def _publish_notices(self) -> None:
|
||||
if self.notice_registry is None:
|
||||
@@ -773,13 +759,15 @@ class Dispatcher:
|
||||
"Autotracking must be enabled in the config to be turned on via MQTT."
|
||||
)
|
||||
return
|
||||
if not ptz_autotracker_settings.enabled:
|
||||
if not self.ptz_metrics[camera_name].autotracker_enabled.value:
|
||||
logger.info(f"Turning on ptz autotracker for {camera_name}")
|
||||
self.ptz_metrics[camera_name].autotracker_enabled.value = True
|
||||
self.ptz_metrics[camera_name].start_time.value = 0
|
||||
ptz_autotracker_settings.enabled = True
|
||||
elif payload == "OFF":
|
||||
if ptz_autotracker_settings.enabled:
|
||||
if self.ptz_metrics[camera_name].autotracker_enabled.value:
|
||||
logger.info(f"Turning off ptz autotracker for {camera_name}")
|
||||
self.ptz_metrics[camera_name].autotracker_enabled.value = False
|
||||
self.ptz_metrics[camera_name].start_time.value = 0
|
||||
ptz_autotracker_settings.enabled = False
|
||||
|
||||
@@ -794,9 +782,7 @@ class Dispatcher:
|
||||
try:
|
||||
payload = int(payload)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Received unsupported value for motion contour area: {payload}"
|
||||
)
|
||||
f"Received unsupported value for motion contour area: {payload}"
|
||||
return
|
||||
|
||||
motion_settings = self.config.cameras[camera_name].motion
|
||||
@@ -813,9 +799,7 @@ class Dispatcher:
|
||||
try:
|
||||
payload = int(payload)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Received unsupported value for motion threshold: {payload}"
|
||||
)
|
||||
f"Received unsupported value for motion threshold: {payload}"
|
||||
return
|
||||
|
||||
motion_settings = self.config.cameras[camera_name].motion
|
||||
@@ -830,9 +814,7 @@ class Dispatcher:
|
||||
def _on_global_notification_command(self, payload: str) -> None:
|
||||
"""Callback for global notification topic."""
|
||||
if payload != "ON" and payload != "OFF":
|
||||
logger.warning(
|
||||
f"Received unsupported value for all notification: {payload}"
|
||||
)
|
||||
f"Received unsupported value for all notification: {payload}"
|
||||
return
|
||||
|
||||
notification_settings = self.config.notifications
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Facilitates communication between processes."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
@@ -80,21 +79,14 @@ class EmbeddingsRequestor:
|
||||
self.context = zmq.Context()
|
||||
self.socket = self.context.socket(zmq.REQ)
|
||||
self.socket.connect(SOCKET_REP_REQ)
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def send_data(self, topic: str, data: Any) -> Any:
|
||||
"""Sends data and then waits for reply."""
|
||||
# an overlapping call fails fast so a slow reply can't stall the API
|
||||
if not self.lock.acquire(blocking=False):
|
||||
return ""
|
||||
|
||||
try:
|
||||
self.socket.send_json((topic, data))
|
||||
return self.socket.recv_json()
|
||||
except zmq.ZMQError:
|
||||
return ""
|
||||
finally:
|
||||
self.lock.release()
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close()
|
||||
|
||||
@@ -8,16 +8,7 @@ from .zmq_proxy import Publisher, Subscriber
|
||||
|
||||
|
||||
class EventUpdatePublisher(
|
||||
Publisher[
|
||||
tuple[
|
||||
EventTypeEnum,
|
||||
EventStateEnum,
|
||||
str | None,
|
||||
str,
|
||||
dict[str, Any],
|
||||
float | None,
|
||||
]
|
||||
]
|
||||
Publisher[tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]]]
|
||||
):
|
||||
"""Publishes events (objects, audio, manual)."""
|
||||
|
||||
@@ -28,14 +19,7 @@ class EventUpdatePublisher(
|
||||
|
||||
def publish(
|
||||
self,
|
||||
payload: tuple[
|
||||
EventTypeEnum,
|
||||
EventStateEnum,
|
||||
str | None,
|
||||
str,
|
||||
dict[str, Any],
|
||||
float | None,
|
||||
],
|
||||
payload: tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]],
|
||||
sub_topic: str = "",
|
||||
) -> None:
|
||||
super().publish(payload, sub_topic)
|
||||
|
||||
@@ -50,13 +50,7 @@ class InterProcessCommunicator(Communicator):
|
||||
|
||||
if isinstance(raw, list):
|
||||
(topic, value) = raw
|
||||
|
||||
# REP owes a reply, so a handler error must not end the thread
|
||||
try:
|
||||
response = self._dispatcher(topic, value)
|
||||
except Exception:
|
||||
logger.exception("Error handling IPC topic %s", topic)
|
||||
response = None
|
||||
response = self._dispatcher(topic, value)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Received unexpected data type in ZMQ recv_json: {type(raw)}"
|
||||
@@ -85,16 +79,14 @@ class InterProcessRequestor:
|
||||
self.context = zmq.Context()
|
||||
self.socket = self.context.socket(zmq.REQ)
|
||||
self.socket.connect(SOCKET_REP_REQ)
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def send_data(self, topic: str, data: Any) -> Any:
|
||||
"""Sends data and then waits for reply."""
|
||||
with self.lock:
|
||||
try:
|
||||
self.socket.send_json((topic, data))
|
||||
return self.socket.recv_json()
|
||||
except zmq.ZMQError:
|
||||
return ""
|
||||
try:
|
||||
self.socket.send_json((topic, data))
|
||||
return self.socket.recv_json()
|
||||
except zmq.ZMQError:
|
||||
return ""
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close(linger=0)
|
||||
|
||||
+3
-58
@@ -2,8 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import selectors
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -58,11 +56,6 @@ 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
|
||||
@@ -92,7 +85,6 @@ 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:
|
||||
@@ -108,11 +100,9 @@ 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:
|
||||
@@ -368,7 +358,7 @@ class MqttClient(Communicator):
|
||||
deadline = time.monotonic() + MQTT_SHUTDOWN_FLUSH_TIMEOUT
|
||||
while not message_info.is_published() and time.monotonic() < deadline:
|
||||
if (
|
||||
self._loop_client(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||
self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||
!= mqtt.MQTT_ERR_SUCCESS
|
||||
):
|
||||
break
|
||||
@@ -378,51 +368,6 @@ 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
|
||||
sock = client.socket()
|
||||
if sock is None:
|
||||
return mqtt.MQTT_ERR_NO_CONN
|
||||
|
||||
events = selectors.EVENT_READ
|
||||
if client.want_write():
|
||||
events |= selectors.EVENT_WRITE
|
||||
# TLS can have decrypted bytes buffered even when the socket is not ready.
|
||||
pending = hasattr(sock, "pending") and sock.pending() > 0
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(sock, events)
|
||||
selector.register(self._wake_recv, selectors.EVENT_READ)
|
||||
ready = {
|
||||
key.fileobj: mask
|
||||
for key, mask in selector.select(0.0 if pending else timeout)
|
||||
}
|
||||
|
||||
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:
|
||||
return result
|
||||
if ready_events & selectors.EVENT_WRITE:
|
||||
result = client.loop_write()
|
||||
if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None:
|
||||
return result
|
||||
return client.loop_misc()
|
||||
|
||||
def _mqtt_loop_worker(self) -> None:
|
||||
# The worker owns all socket I/O so reconnect, subscribe, and publish
|
||||
# ordering stays serialized in one place.
|
||||
@@ -439,7 +384,7 @@ class MqttClient(Communicator):
|
||||
|
||||
assert self.client is not None
|
||||
try:
|
||||
result = self._loop_client(timeout=MQTT_LOOP_TIMEOUT)
|
||||
result = self.client.loop(timeout=MQTT_LOOP_TIMEOUT)
|
||||
except (OSError, mqtt.WebsocketConnectionError) as err:
|
||||
logger.warning("MQTT loop error: %s", err)
|
||||
self._schedule_reconnect()
|
||||
@@ -672,7 +617,7 @@ class MqttClient(Communicator):
|
||||
return
|
||||
|
||||
try:
|
||||
result = self._loop_client(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||
result = self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
|
||||
except (OSError, mqtt.WebsocketConnectionError) as err:
|
||||
logger.warning("MQTT publish wait failed: %s", err)
|
||||
self._schedule_reconnect()
|
||||
|
||||
+34
-159
@@ -1,19 +1,16 @@
|
||||
"""Handle sending notifications for Frigate via Firebase."""
|
||||
|
||||
import datetime
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from py_vapid import Vapid01
|
||||
from pywebpush import WebPusher
|
||||
from titlecase import titlecase
|
||||
@@ -44,130 +41,6 @@ class PushNotification:
|
||||
ttl: int = 0
|
||||
|
||||
|
||||
# Push endpoints are opaque URLs but stay well under this in practice
|
||||
MAX_ENDPOINT_LENGTH = 2048
|
||||
|
||||
# Suffixes that only ever resolve on the local network
|
||||
INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa")
|
||||
|
||||
|
||||
def validate_push_endpoint(endpoint: Any) -> str | None:
|
||||
"""Return a reason the endpoint is unusable, or None when it is valid.
|
||||
|
||||
Subscriptions are issued by the browser vendor's push service, so a valid
|
||||
endpoint is always a public https URL. Anything else is either a broken
|
||||
registration or an attempt to aim the notification sender somewhere it
|
||||
should not reach.
|
||||
"""
|
||||
if not isinstance(endpoint, str) or not endpoint:
|
||||
return "endpoint must be a url"
|
||||
|
||||
if len(endpoint) > MAX_ENDPOINT_LENGTH:
|
||||
return "endpoint is too long"
|
||||
|
||||
try:
|
||||
parsed = urlparse(endpoint)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return "endpoint is not a valid url"
|
||||
|
||||
if parsed.scheme != "https":
|
||||
return "endpoint must use https"
|
||||
|
||||
if parsed.username or parsed.password:
|
||||
return "endpoint must not include credentials"
|
||||
|
||||
if port is not None and port != 443:
|
||||
return "endpoint must use the default https port"
|
||||
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
return "endpoint must include a hostname"
|
||||
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
address = None
|
||||
|
||||
if address is not None:
|
||||
# A push service is never reachable at an address only this network can
|
||||
# route, so anything non-global is a misconfiguration at best
|
||||
if not address.is_global:
|
||||
return "endpoint must not use a private address"
|
||||
elif hostname == "localhost" or "." not in hostname:
|
||||
return "endpoint must use a fully qualified hostname"
|
||||
elif "" in hostname.split("."):
|
||||
return "endpoint hostname must not contain empty labels"
|
||||
elif hostname.endswith(INTERNAL_HOST_SUFFIXES):
|
||||
return "endpoint must not use an internal hostname"
|
||||
|
||||
# The subscription token lives in the path
|
||||
if len(parsed.path) <= 1:
|
||||
return "endpoint must include a subscription path"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class _NoRedirectSession(requests.Session):
|
||||
"""Session that never follows redirects.
|
||||
|
||||
A push endpoint could otherwise redirect the send to an internal URL.
|
||||
"""
|
||||
|
||||
def request(self, *args: Any, **kwargs: Any) -> requests.Response:
|
||||
kwargs["allow_redirects"] = False
|
||||
return super().request(*args, **kwargs)
|
||||
|
||||
|
||||
_push_session = _NoRedirectSession()
|
||||
|
||||
|
||||
def _vapid_audience(endpoint: str) -> str:
|
||||
parsed = urlparse(endpoint)
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
|
||||
def _build_web_pushers(user: str, subs: list[dict[str, Any]]) -> list[WebPusher]:
|
||||
"""Build pushers for a user's stored subscriptions, skipping unusable ones."""
|
||||
pushers: list[WebPusher] = []
|
||||
|
||||
for sub in subs:
|
||||
# Stored rows may predate registration validation, so only skip the
|
||||
# endpoints that would break building the VAPID audience
|
||||
endpoint = sub.get("endpoint") if isinstance(sub, dict) else None
|
||||
|
||||
if not isinstance(endpoint, str):
|
||||
logger.warning(
|
||||
"Skipping notification subscription for %s: no endpoint", user
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
hostname = urlparse(endpoint).hostname or ""
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Skipping notification subscription for %s: unparsable endpoint", user
|
||||
)
|
||||
continue
|
||||
|
||||
if "" in hostname.split("."):
|
||||
logger.warning(
|
||||
"Skipping notification subscription for %s: invalid endpoint host %r",
|
||||
user,
|
||||
hostname,
|
||||
)
|
||||
continue
|
||||
|
||||
# WebPusher decodes the stored keys and raises on malformed ones
|
||||
try:
|
||||
pushers.append(WebPusher(sub, requests_session=_push_session))
|
||||
except Exception:
|
||||
logger.warning("Skipping invalid notification subscription for %s", user)
|
||||
|
||||
return pushers
|
||||
|
||||
|
||||
class WebPushClient(Communicator):
|
||||
"""Frigate wrapper for webpush client."""
|
||||
|
||||
@@ -183,7 +56,6 @@ class WebPushClient(Communicator):
|
||||
for c in self.config.cameras.values()
|
||||
}
|
||||
self.suspension_broadcaster: Callable[[str, Any, bool], None] | None = None
|
||||
self.config_lock = threading.Lock()
|
||||
self.last_camera_notification_time: dict[str, float] = {
|
||||
c.name: 0 # type: ignore[misc]
|
||||
for c in self.config.cameras.values()
|
||||
@@ -200,13 +72,13 @@ 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: Iterator[dict[str, Any]] = (
|
||||
users: list[dict[str, Any]] = (
|
||||
User.select(User.username, User.notification_tokens).dicts().iterator()
|
||||
)
|
||||
for user in users:
|
||||
self.web_pushers[user["username"]] = _build_web_pushers(
|
||||
user["username"], user["notification_tokens"]
|
||||
)
|
||||
self.web_pushers[user["username"]] = []
|
||||
for sub in user["notification_tokens"]:
|
||||
self.web_pushers[user["username"]].append(WebPusher(sub))
|
||||
|
||||
# notification and auth config updater
|
||||
self.global_config_subscriber = ConfigSubscriber("config/")
|
||||
@@ -244,7 +116,7 @@ class WebPushClient(Communicator):
|
||||
for pushers in self.web_pushers.values():
|
||||
for push in pushers:
|
||||
endpoint: str = push.subscription_info["endpoint"]
|
||||
endpoints.add(_vapid_audience(endpoint))
|
||||
endpoints.add(endpoint[0 : endpoint.index("/", 10)])
|
||||
|
||||
# create new claim
|
||||
for endpoint in endpoints:
|
||||
@@ -274,7 +146,10 @@ class WebPushClient(Communicator):
|
||||
User.username == user
|
||||
).execute()
|
||||
|
||||
self.web_pushers[user] = _build_web_pushers(user, user_subs)
|
||||
self.web_pushers[user] = []
|
||||
|
||||
for sub in user_subs:
|
||||
self.web_pushers[user].append(WebPusher(sub))
|
||||
|
||||
logger.info(
|
||||
f"Cleaned up {len(expired)} notification subscriptions for {user}"
|
||||
@@ -323,30 +198,29 @@ class WebPushClient(Communicator):
|
||||
|
||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||
"""Wrapper for publishing when client is in valid state."""
|
||||
with self.config_lock:
|
||||
# check for updated global config (notifications, auth)
|
||||
while True:
|
||||
config_topic, config_payload = (
|
||||
self.global_config_subscriber.check_for_update()
|
||||
)
|
||||
if config_topic is None:
|
||||
break
|
||||
if config_topic == "config/notifications" and config_payload:
|
||||
self.config.notifications = config_payload
|
||||
elif config_topic == "config/auth":
|
||||
if isinstance(config_payload, AuthConfig):
|
||||
self.config.auth = config_payload
|
||||
self._refresh_user_cameras()
|
||||
|
||||
updates = self.config_subscriber.check_for_updates()
|
||||
|
||||
if "add" in updates:
|
||||
for camera in updates["add"]:
|
||||
self.suspended_cameras[camera] = 0
|
||||
self.last_camera_notification_time[camera] = 0
|
||||
|
||||
# check for updated global config (notifications, auth)
|
||||
while True:
|
||||
config_topic, config_payload = (
|
||||
self.global_config_subscriber.check_for_update()
|
||||
)
|
||||
if config_topic is None:
|
||||
break
|
||||
if config_topic == "config/notifications" and config_payload:
|
||||
self.config.notifications = config_payload
|
||||
elif config_topic == "config/auth":
|
||||
if isinstance(config_payload, AuthConfig):
|
||||
self.config.auth = config_payload
|
||||
self._refresh_user_cameras()
|
||||
|
||||
updates = self.config_subscriber.check_for_updates()
|
||||
|
||||
if "add" in updates:
|
||||
for camera in updates["add"]:
|
||||
self.suspended_cameras[camera] = 0
|
||||
self.last_camera_notification_time[camera] = 0
|
||||
|
||||
self._refresh_user_cameras()
|
||||
|
||||
if topic == "reviews":
|
||||
decoded = json.loads(payload)
|
||||
camera = decoded["before"]["camera"]
|
||||
@@ -433,8 +307,9 @@ class WebPushClient(Communicator):
|
||||
|
||||
for pusher in self.web_pushers[notification.user]:
|
||||
endpoint = pusher.subscription_info["endpoint"]
|
||||
|
||||
headers = self.claim_headers[_vapid_audience(endpoint)].copy()
|
||||
headers = self.claim_headers[
|
||||
endpoint[: endpoint.index("/", 10)]
|
||||
].copy()
|
||||
headers["urgency"] = "high"
|
||||
|
||||
resp = pusher.send(
|
||||
|
||||
+4
-82
@@ -3,9 +3,6 @@
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
@@ -79,12 +76,6 @@ _WS_VIEWER_TOPICS = frozenset(
|
||||
# Camera-scoped command topics a camera-authorized (non-admin) user may send.
|
||||
_WS_CAMERA_COMMAND_TOPICS = frozenset({"ptz"})
|
||||
|
||||
# Max outbound messages waiting on a client's writer thread.
|
||||
WS_MAX_PENDING_MESSAGES = 256
|
||||
|
||||
# Seconds a single send() may wait for a client to free socket buffer space.
|
||||
WS_SEND_TIMEOUT = 10
|
||||
|
||||
|
||||
def _check_ws_authorization(
|
||||
topic: str,
|
||||
@@ -473,78 +464,6 @@ def _materialize_for_ws(
|
||||
|
||||
|
||||
class WebSocket(WebSocket_): # type: ignore[misc]
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._send_queue: queue.Queue[tuple[Any, bool] | None] = queue.Queue(
|
||||
maxsize=WS_MAX_PENDING_MESSAGES
|
||||
)
|
||||
self._writer: threading.Thread | None = None
|
||||
self._aborted = False
|
||||
|
||||
def opened(self) -> None:
|
||||
"""Set a send timeout on the socket and start this client's writer thread."""
|
||||
|
||||
# ws4py writes close and pong frames on the shared manager thread, so a
|
||||
# stalled client must not block sendall forever. SO_SNDTIMEO leaves recv
|
||||
# (and idle clients) untouched, unlike settimeout().
|
||||
self.sock.setsockopt(
|
||||
socket.SOL_SOCKET, socket.SO_SNDTIMEO, struct.pack("ll", WS_SEND_TIMEOUT, 0)
|
||||
)
|
||||
|
||||
# every client gets its own writer so a client that stops reading only
|
||||
# blocks itself, never the thread that called publish()
|
||||
self._writer = threading.Thread(
|
||||
target=self._drain_send_queue, name="ws_writer", daemon=True
|
||||
)
|
||||
self._writer.start()
|
||||
|
||||
def send(self, payload: Any, binary: bool = False) -> None:
|
||||
"""Queue a message for this client's writer thread.
|
||||
|
||||
Returns without waiting for delivery. Disconnects the client if its queue
|
||||
is full.
|
||||
"""
|
||||
try:
|
||||
self._send_queue.put_nowait((payload, binary))
|
||||
except queue.Full:
|
||||
self._abort("Websocket client is not keeping up, disconnecting it")
|
||||
|
||||
def _drain_send_queue(self) -> None:
|
||||
while True:
|
||||
item = self._send_queue.get()
|
||||
if item is None or self.terminated or self.sock is None:
|
||||
return
|
||||
try:
|
||||
super().send(*item)
|
||||
except Exception:
|
||||
self._abort()
|
||||
return
|
||||
|
||||
def _abort(self, reason: str | None = None) -> None:
|
||||
# publish() keeps hitting a full queue until the manager thread removes
|
||||
# the connection, so only act (and log) the first time
|
||||
if self._aborted:
|
||||
return
|
||||
self._aborted = True
|
||||
if reason:
|
||||
logger.warning(reason)
|
||||
|
||||
# shutdown rather than close so the ws4py manager thread sees EOF and
|
||||
# runs its normal unregister/terminate; this also unblocks a stuck sendall
|
||||
sock = self.sock
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def closed(self, code: int, reason: str | None = None) -> None:
|
||||
"""Signal the writer thread to exit."""
|
||||
try:
|
||||
self._send_queue.put_nowait(None)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def unhandled_error(self, error: Any) -> None:
|
||||
"""
|
||||
Handles the unfriendly socket closures on the server side
|
||||
@@ -678,7 +597,10 @@ class WebSocketClient(Communicator):
|
||||
)
|
||||
if message is None:
|
||||
continue
|
||||
ws.send(message)
|
||||
try:
|
||||
ws.send(message)
|
||||
except (ConnectionResetError, BrokenPipeError, ValueError):
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.websocket_server is not None:
|
||||
|
||||
+1
-11
@@ -1,5 +1,3 @@
|
||||
import ipaddress
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
from .base import FrigateBaseModel
|
||||
@@ -27,7 +25,7 @@ class AuthConfig(FrigateBaseModel):
|
||||
cookie_secure: bool = Field(
|
||||
default=False,
|
||||
title="Secure cookie flag",
|
||||
description="Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS.",
|
||||
description="Set the secure flag on the auth cookie; should be true when using TLS.",
|
||||
)
|
||||
session_length: int = Field(
|
||||
default=86400,
|
||||
@@ -70,14 +68,6 @@ class AuthConfig(FrigateBaseModel):
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("trusted_proxies")
|
||||
@classmethod
|
||||
def validate_trusted_proxies(cls, v: list[str]) -> list[str]:
|
||||
for proxy in v:
|
||||
ipaddress.ip_network(proxy, strict=False)
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("roles")
|
||||
@classmethod
|
||||
def validate_roles(cls, v: dict[str, list[str]]) -> dict[str, list[str]]:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from frigate.detectors.detector_config import DEFAULT_SCENE, SCENE_PATTERN
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
@@ -62,11 +62,10 @@ class DetectConfig(FrigateBaseModel):
|
||||
title="Detect width",
|
||||
description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
|
||||
)
|
||||
scene: str = Field(
|
||||
default=DEFAULT_SCENE,
|
||||
pattern=SCENE_PATTERN,
|
||||
scene: SceneEnum = Field(
|
||||
default=SceneEnum.all,
|
||||
title="Detect scene",
|
||||
description="The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'.",
|
||||
description="The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'.",
|
||||
)
|
||||
fps: int = Field(
|
||||
default=5,
|
||||
|
||||
@@ -3,9 +3,9 @@ from enum import Enum
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from frigate.util.config import resolve_ffmpeg_path
|
||||
from frigate.util.env import EnvString
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from ..env import EnvString
|
||||
|
||||
__all__ = [
|
||||
"CameraFfmpegConfig",
|
||||
|
||||
@@ -3,9 +3,8 @@ from typing import Any, Self
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from frigate.util.env import EnvString
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from ..env import EnvString
|
||||
|
||||
__all__ = ["GenAIConfig", "GenAIProviderEnum", "GenAIRoleEnum"]
|
||||
|
||||
|
||||
@@ -1,57 +1,8 @@
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from frigate.util.live_streams import DEFAULT_TRANSCODE_QUALITIES
|
||||
from pydantic import Field
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
__all__ = ["CameraLiveConfig", "LiveTranscodeConfig", "LiveTranscodeQualityConfig"]
|
||||
|
||||
|
||||
class LiveTranscodeQualityConfig(FrigateBaseModel):
|
||||
height: int = Field(
|
||||
ge=144,
|
||||
le=2160,
|
||||
title="Height",
|
||||
description="Output height in pixels; width follows the source aspect ratio.",
|
||||
)
|
||||
bitrate: int = Field(
|
||||
ge=64,
|
||||
title="Bitrate",
|
||||
description="Target and maximum video bitrate in kbps.",
|
||||
)
|
||||
|
||||
|
||||
class LiveTranscodeConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Enable transcoded streams",
|
||||
description="Add lower-quality live streams that go2rtc transcodes in real time while someone is watching.",
|
||||
)
|
||||
source: str | None = Field(
|
||||
default=None,
|
||||
title="Source stream",
|
||||
description="go2rtc stream to transcode. Defaults to the first live stream.",
|
||||
)
|
||||
qualities: list[LiveTranscodeQualityConfig] = Field(
|
||||
default_factory=lambda: [
|
||||
LiveTranscodeQualityConfig(**quality)
|
||||
for quality in DEFAULT_TRANSCODE_QUALITIES
|
||||
],
|
||||
title="Qualities",
|
||||
description="One transcoded stream is added per quality.",
|
||||
)
|
||||
|
||||
@field_validator("qualities")
|
||||
@classmethod
|
||||
def validate_unique_heights(
|
||||
cls, qualities: list[LiveTranscodeQualityConfig]
|
||||
) -> list[LiveTranscodeQualityConfig]:
|
||||
heights = [quality.height for quality in qualities]
|
||||
|
||||
if len(heights) != len(set(heights)):
|
||||
raise ValueError("Transcoded stream heights must be unique.")
|
||||
|
||||
return qualities
|
||||
__all__ = ["CameraLiveConfig"]
|
||||
|
||||
|
||||
class CameraLiveConfig(FrigateBaseModel):
|
||||
@@ -60,11 +11,6 @@ class CameraLiveConfig(FrigateBaseModel):
|
||||
title="Live stream names",
|
||||
description="Mapping of configured stream names to restream/go2rtc names used for live playback.",
|
||||
)
|
||||
transcode: LiveTranscodeConfig = Field(
|
||||
default_factory=LiveTranscodeConfig,
|
||||
title="Transcoded streams",
|
||||
description="Lower-quality live streams transcoded on demand by go2rtc.",
|
||||
)
|
||||
height: int = Field(
|
||||
default=720,
|
||||
title="Live height",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from pydantic import Field
|
||||
|
||||
from frigate.util.env import EnvString
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
__all__ = ["NotificationConfig"]
|
||||
@@ -13,7 +11,7 @@ class NotificationConfig(FrigateBaseModel):
|
||||
title="Enable notifications",
|
||||
description="Enable or disable notifications for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
email: EnvString | None = Field(
|
||||
email: str | None = Field(
|
||||
default=None,
|
||||
title="Notification email",
|
||||
description="Email address used for push notifications or required by certain notification providers.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user