Compare commits

..
Author SHA1 Message Date
Nicolas Mowen abf8a1875c Update api spec 2026-08-22 09:13:08 -06:00
Nicolas Mowen 172b404a93 Require camera access to delete review for camera 2026-08-22 09:12:27 -06:00
264 changed files with 5553 additions and 22041 deletions

No files matched your search

-83
View File
@@ -42,89 +42,6 @@ jobs:
tags: ${{ steps.setup.outputs.image-name }}-amd64
cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64
cache-to: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64,mode=max
smoke_test:
runs-on: ubuntu-22.04
name: AMD64 Smoke Test
needs:
- amd64_build
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up QEMU and Buildx
id: setup
uses: ./.github/actions/setup
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Start container
run: |
mkdir -p /tmp/frigate-config
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config/config.yml
docker run -d --name frigate --shm-size 256m \
-v /tmp/frigate-config:/config \
-p 5000:5000 -p 8971:8971 \
${{ steps.setup.outputs.image-name }}-amd64
- name: Wait for API
run: |
for i in $(seq 1 60); do
curl -fs http://127.0.0.1:5000/api/version && exit 0
sleep 5
done
echo "API never came up"; docker logs frigate; exit 1
- name: Assert security headers and permissions
run: |
headers=$(curl -ksI https://127.0.0.1:8971/)
echo "$headers"
echo "$headers" | grep -qi "x-content-type-options: nosniff"
echo "$headers" | grep -qi "referrer-policy: strict-origin-when-cross-origin"
# server_tokens off: Server header must not include a version.
# written as an if rather than "! grep", because bash exempts a
# negated command from set -e and the assertion would never fail
if echo "$headers" | grep -qiE "^server: nginx/[0-9]"; then
echo "Server header leaks the nginx version; server_tokens is not off"
exit 1
fi
# Frigate never ships frame-ancestors: HA's Webpage card and iframe
# panels frame it cross-origin and it would break them silently
if echo "$headers" | grep -qi "frame-ancestors"; then
echo "response carries frame-ancestors, which breaks cross-origin iframe embedding"
exit 1
fi
docker exec frigate /usr/local/nginx/sbin/nginx -t
docker exec frigate stat -c %a /etc/letsencrypt/live/frigate/privkey.pem | grep -qx 600
docker exec frigate stat -c %a /dev/shm/go2rtc.yaml | grep -qx 640
- name: Assert PUID/PGID remapping
run: |
mkdir -p /tmp/frigate-config-puid
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-puid/config.yml
docker run -d --name frigate-puid --shm-size 256m \
-e PUID=1500 -e PGID=1500 \
-v /tmp/frigate-config-puid:/config \
${{ steps.setup.outputs.image-name }}-amd64
up=0
for i in $(seq 1 60); do
docker exec frigate-puid curl -fs http://127.0.0.1:5000/api/version && up=1 && break
sleep 5
done
if [ "$up" -ne 1 ]; then echo "PUID container never became healthy"; docker logs frigate-puid; exit 1; fi
docker exec frigate-puid id -u frigate | grep -qx 1500
docker exec frigate-puid id -g frigate | grep -qx 1500
docker exec frigate-puid cat /config/.permissions_version | grep -qx "1:1500:1500"
# second boot must skip the sweep (sentinel hit). Poll rather than
# sleep: the string can only come from the second boot (the first
# had no sentinel), so grepping the full log is unambiguous.
docker restart frigate-puid
ok=0
for i in $(seq 1 30); do
docker logs frigate-puid 2>&1 | grep -q "already applied" && ok=1 && break
sleep 2
done
if [ "$ok" -ne 1 ]; then echo "sentinel skip never logged"; docker logs frigate-puid; exit 1; fi
docker rm -f frigate-puid
- name: Teardown
if: always()
run: docker rm -f frigate || true
arm64_build:
runs-on: ubuntu-22.04-arm
name: ARM Build
+1 -1
View File
@@ -1,7 +1,7 @@
default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.19.0
VERSION = 0.18.0
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
BOARDS= #Initialized empty
+3 -25
View File
@@ -60,10 +60,10 @@ ARG DEBIAN_FRONTEND
RUN --mount=type=bind,source=docker/main/build_intel_media_driver.sh,target=/deps/build_intel_media_driver.sh \
/deps/build_intel_media_driver.sh
FROM wget AS go2rtc
FROM scratch AS go2rtc
ARG TARGETARCH
RUN --mount=type=bind,source=docker/main/install_go2rtc.sh,target=/deps/install_go2rtc.sh \
/deps/install_go2rtc.sh
WORKDIR /rootfs/usr/local/go2rtc/bin
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.14/go2rtc_linux_${TARGETARCH}" go2rtc
FROM wget AS tempio
ARG TARGETARCH
@@ -265,23 +265,6 @@ ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PA
RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_deps.sh \
/deps/install_deps.sh
# Runtime users. frigate may be remapped at start via PUID/PGID (init-usermod)
# or replaced entirely with docker's --user. go2rtc is intentionally separate
# and more restricted. frigate-data is the shared group for /config access.
# -o tolerates variant base images that already contain uid/gid 1000.
RUN groupadd -o --gid 1000 frigate \
&& useradd -o --uid 1000 --gid frigate --no-create-home --shell /usr/sbin/nologin frigate \
&& groupadd --system go2rtc \
&& useradd --system --gid go2rtc --no-create-home --shell /usr/sbin/nologin go2rtc \
&& groupadd --system frigate-data \
&& usermod -aG frigate-data frigate \
&& usermod -aG frigate-data go2rtc \
&& for grp in video render plugdev audio; do \
if getent group "$grp" >/dev/null; then \
usermod -aG "$grp" frigate && usermod -aG "$grp" go2rtc; \
fi; \
done
ENV DEFAULT_FFMPEG_VERSION="8.0"
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0"
@@ -324,11 +307,6 @@ HEALTHCHECK --start-period=300s --start-interval=5s --interval=15s --timeout=5s
# Frigate deps with Node.js and NPM for devcontainer
FROM deps AS devcontainer
# /config here is the developer's bind-mounted checkout, not a data volume, so
# the prepare ownership sweep must not run: it would chown the source tree to
# the runtime uid and lock out any container user that isn't 1000.
ENV FRIGATE_RUN_AS_ROOT=true
# Do not start the actual Frigate service on devcontainer as it will be started by VS Code
# But start a fake service for simulating the logs
COPY docker/main/fake_frigate_run /etc/s6-overlay/s6-rc.d/frigate/run
+37 -77
View File
@@ -28,13 +28,7 @@ update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
mkdir -p -m 600 /root/.gnupg
# install coral runtime
# sha256 digests of the release debs; update when bumping the libedgetpu release.
declare -A edgetpu_checksums=(
["amd64"]="63fd00989d29160fa9894e115156a9abe456e88751fc9be89d26e4696200441b"
["arm64"]="eab8aa4576b4dbf738135d8094f32270b24117f77147d25cbe0f49d0144d85f2"
)
wget -q -O /tmp/libedgetpu1-max.deb "https://github.com/feranick/libedgetpu/releases/download/16.0TF2.17.1-1/libedgetpu1-max_16.0tf2.17.1-1.bookworm_${TARGETARCH}.deb"
echo "${edgetpu_checksums[${TARGETARCH}]} /tmp/libedgetpu1-max.deb" | sha256sum -c -
unset DEBIAN_FRONTEND
yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive
rm /tmp/libedgetpu1-max.deb
@@ -51,41 +45,36 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
fi
fi
# sha256 digests of the ffmpeg builds, keyed "<install dir>-<arch>".
# Upstream publishes no checksums; these come from a one-time fetch and guard
# against later substitution. Update when bumping a build URL.
declare -A ffmpeg_checksums=(
["5.0-amd64"]="377abec133f9d9e8014dee1b91c9684ac8bb0b5b7d80100a57116ff837c4c0d4"
["7.0-amd64"]="e13860eb90409c8218319c928067834ce450128e86f24cfed5cfe91ce6e31037"
["8.0-amd64"]="9bac85054d351cdc89c0a4f45c8ea5c44df94009aabd964b719bbadd56aedae9"
["5.0-arm64"]="57ee475407bad49910ba9b946428396e30cf075ea28a7912fbe1aa2578085af0"
["7.0-arm64"]="16c8b04e9d0ea9c769ad964c4c453fcf05121a1947237329d2e9d8a5e43e2a3c"
["8.0-arm64"]="cd91948468d0f11ce795a2cdaa0c69911bd1db313b49bb19c22512beb88cde69"
)
# the tarballs nest their binaries under a directory named for the arch, which
# matches TARGETARCH for both builds we consume
install_ffmpeg() {
local dir="$1" url="$2"
mkdir -p "/usr/lib/ffmpeg/${dir}"
wget -qO ffmpeg.tar.xz "${url}"
echo "${ffmpeg_checksums[${dir}-${TARGETARCH}]} ffmpeg.tar.xz" | sha256sum -c -
tar -xf ffmpeg.tar.xz -C "/usr/lib/ffmpeg/${dir}" --strip-components 1 "${TARGETARCH}/bin/ffmpeg" "${TARGETARCH}/bin/ffprobe"
rm -f ffmpeg.tar.xz
}
# ffmpeg -> amd64
if [[ "${TARGETARCH}" == "amd64" ]]; then
install_ffmpeg 5.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linux64-gpl-5.1.tar.xz"
install_ffmpeg 7.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
install_ffmpeg 8.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linux64-gpl-8.1.tar.xz"
mkdir -p /usr/lib/ffmpeg/5.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linux64-gpl-5.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/8.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linux64-gpl-8.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
fi
# ffmpeg -> arm64
if [[ "${TARGETARCH}" == "arm64" ]]; then
install_ffmpeg 5.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linuxarm64-gpl-5.1.tar.xz"
install_ffmpeg 7.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
install_ffmpeg 8.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linuxarm64-gpl-8.1.tar.xz"
mkdir -p /usr/lib/ffmpeg/5.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linuxarm64-gpl-5.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/8.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linuxarm64-gpl-8.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
fi
# arch specific packages
@@ -131,56 +120,27 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
apt-get -qq install -y libtbb12
# install legacy and standard intel compute packages
# sha256 digests of the driver debs, taken from the ww<week>.sum asset
# compute-runtime ships per release and the checksum.sha256 on npu-driver
# v1.19.0; intel-graphics-compiler and level-zero publish none, so those
# five are hash-what-you-get. Refresh after a version bump with
# `curl -sL <url> | sha256sum`, cross-checking upstream's sum where the
# release still has one. npu-driver stopped publishing them after v1.19.0.
declare -A intel_checksums=(
["libigdgmm12_22.9.0_amd64.deb"]="9d712f71c18baee076de9961dda71e8089291e1bd0deb5d649ab5ba5de114f97"
["intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb"]="bbe71e4f414259e06a10cde72c29a2bd78d41b2bb2f6f8463b1806797fe66e85"
["intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb"]="40dfbd15ab62de036a00824b304a2aa1fa2d81ad60ef83da09cfe3c5a80c429f"
["intel-igc-opencl_1.0.17537.24_amd64.deb"]="dd016400f87fa2b6a9fa9fbcca7eb4a2629174a29de679709f9bec5cede88b0e"
["intel-igc-core_1.0.17537.24_amd64.deb"]="c1e1ecdfe2064c047c552651cfdcdafc504f2033afafba65654338b880048b67"
["intel-opencl-icd_26.14.37833.4-0_amd64.deb"]="2e15eeb4fe9c1bba467a655967373eec6a20dd04cc7159de53c359f17ab53e41"
["libze-intel-gpu1_26.14.37833.4-0_amd64.deb"]="34ce5791160d87ce6d54edb558a4030858ee1dad2afb067b9c5c58d4cde774c6"
["intel-igc-opencl-2_2.32.7+21184_amd64.deb"]="3c9bddbfe558279402bbeaabcf9c63b8de46b956b0ad9625415fd35dda53ad52"
["intel-igc-core-2_2.32.7+21184_amd64.deb"]="64e5230788e3a31e611e8d815a141b1facb91e5f0ef239233ef3f0614bfe3fd6"
["level-zero_1.28.2+u22.04_amd64.deb"]="9015a579abef960166f8e943858d5c81fd4199a960f07260c1da66038257effb"
["intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb"]="8087bfcc0872d7976d0163203c7c783a4176f813c473766587e86c7b34135dff"
["intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb"]="740219c03495f8812c03ab74baf8199acf17d13929001105418d4ba226ba2290"
["intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb"]="f4f5eb97aa7da52c7fec97e4ddfb43aae01703bbadc767bae1f2d4faf342ba42"
)
fetch_intel_deb() {
local url="$1" name
name=$(basename "$url")
wget -q "$url"
echo "${intel_checksums[${name}]} ${name}" | sha256sum -c -
}
# see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info
# needed core package
fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
dpkg -i libigdgmm12_22.9.0_amd64.deb
rm libigdgmm12_22.9.0_amd64.deb
# legacy compute-runtime packages
fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb
fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb
fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
# standard compute-runtime packages
fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb
fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb
fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb
fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb
# npu packages
fetch_intel_deb https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero_1.28.2+u22.04_amd64.deb
fetch_intel_deb https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
fetch_intel_deb https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
fetch_intel_deb https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
wget https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero_1.28.2+u22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
dpkg -i *.deb
rm *.deb
-19
View File
@@ -1,19 +0,0 @@
#!/bin/bash
set -euxo pipefail
go2rtc_version="1.9.14"
# sha256 digests of the release binaries; update when bumping go2rtc_version.
declare -A go2rtc_checksums=(
["amd64"]="32d616af226bd731678ffde328b94cfb94e30339bfefc469cfb76323144615a6"
["arm64"]="359fabade8a7a51e81a55fe6df6b0ef81764a5e1d63179577534eaaa71904b50"
)
dest_dir="/rootfs/usr/local/go2rtc/bin"
mkdir -p "${dest_dir}"
wget -qO "${dest_dir}/go2rtc" \
"https://github.com/AlexxIT/go2rtc/releases/download/v${go2rtc_version}/go2rtc_linux_${TARGETARCH}"
echo "${go2rtc_checksums[${TARGETARCH}]} ${dest_dir}/go2rtc" | sha256sum -c -
chmod 755 "${dest_dir}/go2rtc"
+2 -20
View File
@@ -4,29 +4,11 @@ set -euxo pipefail
hailo_version="4.21.0"
# sha256 digests of the release artifacts; update when bumping hailo_version.
# The runtime tarball is keyed by TARGETARCH, the wheel by the python arch tag.
declare -A hailort_checksums=(
["amd64"]="0a57ac5f7cc8c2c3668133189d9285b55f498e8cb219797e203f6f5015fec4b3"
["arm64"]="dd840548eb5d0d147c99aee2cb013d39d64be09c5bc63061171fcfacf4547b3f"
["x86_64"]="8112a973ab48095399b29d883f31987828df5861b8553f614c89f098a67b3fb6"
["aarch64"]="658432a43573280d472f6402d7934669effe7f163ba3dffa31c50bbeeaa7c01d"
)
if [[ "${TARGETARCH}" == "amd64" ]]; then
arch="x86_64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then
arch="aarch64"
fi
# downloaded rather than streamed into tar because streaming and verifying the
# digest before extraction are mutually exclusive
wget -qO /tmp/hailort.tar.gz "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz"
echo "${hailort_checksums[${TARGETARCH}]} /tmp/hailort.tar.gz" | sha256sum -c -
tar -C / -xzf /tmp/hailort.tar.gz
rm -f /tmp/hailort.tar.gz
wheel="/wheels/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
mkdir -p /wheels
wget -qO "${wheel}" "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
echo "${hailort_checksums[${arch}]} ${wheel}" | sha256sum -c -
wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz" | tar -C / -xzf -
wget -P /wheels/ "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
+4 -20
View File
@@ -4,15 +4,6 @@ set -euxo pipefail
s6_version="3.2.1.0"
# sha256 digests of the release artifacts, from the .sha256 files published at
# https://github.com/just-containers/s6-overlay/releases/tag/v3.2.1.0
# Update these when bumping s6_version.
declare -A s6_checksums=(
["noarch"]="42e038a9a00fc0fef70bf0bc42f625a9c14f8ecdfe77d4ad93281edf717e10c5"
["x86_64"]="8bcbc2cada58426f976b159dcc4e06cbb1454d5f39252b3bb0c778ccf71c9435"
["aarch64"]="c8fd6b1f0380d399422fc986a1e6799f6a287e2cfa24813ad0b6a4fb4fa755cc"
)
if [[ "${TARGETARCH}" == "amd64" ]]; then
s6_arch="x86_64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then
@@ -21,15 +12,8 @@ fi
mkdir -p /rootfs/
download_and_extract() {
local arch="$1"
local tarball="/tmp/s6-overlay-${arch}.tar.xz"
wget -qO "${tarball}" \
"https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-${arch}.tar.xz"
echo "${s6_checksums[${arch}]} ${tarball}" | sha256sum -c -
tar -C /rootfs/ -Jxpf "${tarball}"
rm -f "${tarball}"
}
wget -qO- "https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-noarch.tar.xz" |
tar -C /rootfs/ -Jxpf -
download_and_extract "noarch"
download_and_extract "${s6_arch}"
wget -qO- "https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-${s6_arch}.tar.xz" |
tar -C /rootfs/ -Jxpf -
-9
View File
@@ -4,14 +4,6 @@ set -euxo pipefail
tempio_version="2021.09.0"
# sha256 digests of the release binaries; update when bumping tempio_version.
# Upstream publishes no checksums, so these come from a one-time fetch and
# guard against later substitution rather than the original download.
declare -A tempio_checksums=(
["amd64"]="b7b93ebfd24c1161cec7aecfad62ab51f2241149358cef354b86cdbc6a60546f"
["aarch64"]="3a5c32981ba68b75ed9b28497429e5a5cecbeb74c3b821b035a48b37609bb895"
)
if [[ "${TARGETARCH}" == "amd64" ]]; then
arch="amd64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then
@@ -21,5 +13,4 @@ fi
mkdir -p /rootfs/usr/local/tempio/bin
wget -q -O /rootfs/usr/local/tempio/bin/tempio "https://github.com/home-assistant/tempio/releases/download/${tempio_version}/tempio_${arch}"
echo "${tempio_checksums[${arch}]} /rootfs/usr/local/tempio/bin/tempio" | sha256sum -c -
chmod 755 /rootfs/usr/local/tempio/bin/tempio
@@ -1,12 +1,4 @@
#!/command/with-contenv bash
# shellcheck shell=bash
if [[ "$(id -u)" -eq 0 ]]; then
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
exec logutil-service /dev/shm/logs/certsync
fi
# Non-root (--user) fallback: logutil-service cannot change UID, so run
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
# shellcheck disable=SC2086
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/certsync
exec logutil-service /dev/shm/logs/certsync
@@ -1,12 +1,4 @@
#!/command/with-contenv bash
# shellcheck shell=bash
if [[ "$(id -u)" -eq 0 ]]; then
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
exec logutil-service /dev/shm/logs/frigate
fi
# Non-root (--user) fallback: logutil-service cannot change UID, so run
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
# shellcheck disable=SC2086
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/frigate
exec logutil-service /dev/shm/logs/frigate
@@ -1,12 +1,4 @@
#!/command/with-contenv bash
# shellcheck shell=bash
if [[ "$(id -u)" -eq 0 ]]; then
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
exec logutil-service /dev/shm/logs/go2rtc
fi
# Non-root (--user) fallback: logutil-service cannot change UID, so run
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
# shellcheck disable=SC2086
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/go2rtc
exec logutil-service /dev/shm/logs/go2rtc
@@ -1,61 +0,0 @@
#!/command/with-contenv bash
# shellcheck shell=bash
# Remap the frigate user to PUID/PGID and register EXTRA_GROUPS.
# No-op when: started with --user (euid != 0), FRIGATE_RUN_AS_ROOT=true,
# or PUID/PGID already match.
set -o errexit -o nounset -o pipefail
if [[ "$(id -u)" -ne 0 ]]; then
# Started with docker --user; the host owns UID mapping entirely.
exit 0
fi
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then
echo "[INFO] FRIGATE_RUN_AS_ROOT=true: skipping user remapping"
exit 0
fi
puid="${PUID:-1000}"
pgid="${PGID:-1000}"
if ! [[ "$puid" =~ ^[0-9]+$ && "$pgid" =~ ^[0-9]+$ ]]; then
echo "[ERROR] PUID and PGID must be numeric, got '${puid}' and '${pgid}'" >&2
exit 1
fi
# Remapping to 0 would make the frigate user root, so every service would keep
# full privilege while reporting a successful migration.
if [[ "$puid" -eq 0 || "$pgid" -eq 0 ]]; then
echo "[ERROR] PUID/PGID 0 would run the services as root and defeat the privilege separation." >&2
echo "[ERROR] Set FRIGATE_RUN_AS_ROOT=true if you want to keep running as root." >&2
exit 1
fi
current_uid="$(id -u frigate)"
current_gid="$(id -g frigate)"
if [[ "$puid" != "$current_uid" || "$pgid" != "$current_gid" ]]; then
if [[ ! -w /etc/passwd ]]; then
echo "[ERROR] PUID/PGID remapping needs a writable /etc and is not compatible with read_only: true." >&2
echo "[ERROR] Either remove read_only and keep PUID, or drop PUID/PGID and use docker's user: ${puid}:${pgid} instead." >&2
echo "[ERROR] See https://docs.frigate.video/configuration/non_root for the compatibility matrix." >&2
exit 1
fi
echo "[INFO] Remapping frigate user to ${puid}:${pgid}"
groupmod -o -g "$pgid" frigate
usermod -o -u "$puid" frigate
fi
# EXTRA_GROUPS: numeric host GIDs granting device access (e.g. host render/video)
if [[ -n "${EXTRA_GROUPS:-}" ]]; then
for gid in ${EXTRA_GROUPS//,/ }; do
if ! getent group "$gid" >/dev/null; then
groupadd -o -g "$gid" "frigate-extra-${gid}"
fi
group_name="$(getent group "$gid" | cut -d: -f1)"
usermod -aG "$group_name" frigate
usermod -aG "$group_name" go2rtc
echo "[INFO] Added frigate and go2rtc to supplementary group ${group_name} (gid ${gid})"
done
fi
@@ -1 +0,0 @@
oneshot
@@ -1 +0,0 @@
/etc/s6-overlay/s6-rc.d/init-usermod/run
@@ -7,12 +7,5 @@ set -o errexit -o nounset -o pipefail
dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx /dev/shm/logs/certsync)
mkdir -p "${dirs[@]}"
# logutil-service drops s6-log to nobody, so the dirs must stay nobody-owned
# in root mode. Under docker --user we are already the (only) target user,
# chown would fail, and the plain s6-log fallback in the *-log services
# writes as us (the mkdir above is sufficient, /dev/shm is 1777).
if [[ "$(id -u)" -eq 0 ]]; then
chown nobody:nogroup "${dirs[@]}"
fi
chown nobody:nogroup "${dirs[@]}"
chmod 02755 "${dirs[@]}"
@@ -1,12 +1,4 @@
#!/command/with-contenv bash
# shellcheck shell=bash
if [[ "$(id -u)" -eq 0 ]]; then
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
exec logutil-service /dev/shm/logs/nginx
fi
# Non-root (--user) fallback: logutil-service cannot change UID, so run
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
# shellcheck disable=SC2086
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/nginx
exec logutil-service /dev/shm/logs/nginx
@@ -77,20 +77,15 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.
openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \
-keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null
chmod 600 "$letsencrypt_path/privkey.pem"
chmod 644 "$letsencrypt_path/fullchain.pem"
fi
# nginx settings are read once; both templates consume them
nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py)
# build templates for optional FRIGATE_BASE_PATH environment variable
echo "$nginx_settings" | \
python3 /usr/local/nginx/get_nginx_settings.py | \
tempio -template /usr/local/nginx/templates/base_path.gotmpl \
-out /usr/local/nginx/conf/base_path.conf
# build templates for additional network settings
echo "$nginx_settings" | \
python3 /usr/local/nginx/get_nginx_settings.py | \
tempio -template /usr/local/nginx/templates/listen.gotmpl \
-out /usr/local/nginx/conf/listen.conf
@@ -144,16 +144,3 @@ rm -f /dev/shm/.frigate-is-stopping
migrate_addon_config_dir
migrate_db_from_media_to_config
# Align volume ownership with the runtime user (one sweep per PUID/schema
# change, guarded by the sentinel; see fix-ownership). The escape hatch
# deletes the sentinel instead: ownership is never mutated while it is on,
# so the next non-root boot must re-sweep whatever root created meanwhile.
if [[ "$(id -u)" -eq 0 ]]; then
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then
rm -f /config/.permissions_version
else
/usr/local/bin/fix-ownership --sentinel /config/.permissions_version \
"${PUID:-1000}" "${PGID:-1000}" /config /media/frigate
fi
fi
@@ -1,129 +0,0 @@
#!/bin/bash
# Single source of truth for aligning volume ownership with the runtime user.
#
# Usage: fix-ownership [--dry-run] [--sentinel FILE] UID GID PATH [PATH...]
#
# --dry-run report what would change, touch nothing
# --sentinel skip entirely when FILE already records "SCHEMA:UID:GID";
# write it after a successful run (used by the boot path so
# multi-TB volumes are swept once per UID/schema change, not
# on every boot)
#
# Only files whose uid OR gid differs are touched, so re-runs are cheap.
# Top-level /config additionally grants group frigate-data TRAVERSE ONLY
# (g+rx) so the separate go2rtc user can reach its pre-created HomeKit file
# on hosts where /config is mounted 0700. Never g+w: directory write means
# unlink rights over frigate.db/config.yml, and would let a compromised
# go2rtc plant /config/go2rtc, which the go2rtc run script executes
# preferentially, as root under the escape hatch.
set -o errexit -o nounset -o pipefail
# Permissions-layout epoch. Bump to force a one-time re-sweep on upgrade
# (e.g. when the privilege-drop release must capture files created as root
# since the previous sweep).
schema=1
dry_run=0
sentinel=""
while [[ "${1:-}" == --* ]]; do
case "$1" in
--dry-run) dry_run=1; shift ;;
--sentinel)
if [[ -z "${2:-}" ]]; then
echo "[ERROR] fix-ownership: --sentinel requires a file argument" >&2
exit 2
fi
sentinel="$2"; shift 2 ;;
*) echo "[ERROR] fix-ownership: unknown option $1" >&2; exit 2 ;;
esac
done
if [[ $# -lt 3 ]]; then
echo "Usage: fix-ownership [--dry-run] [--sentinel FILE] UID GID PATH..." >&2
exit 2
fi
target_uid="$1"
target_gid="$2"
shift 2
if [[ "$(id -u)" -ne 0 ]]; then
echo "[INFO] fix-ownership: not running as root, skipping (ownership is managed by the host in --user mode)"
exit 0
fi
# A dry run always inspects: the sentinel records what a past sweep did, not
# what the volume looks like now, and reporting from it would hide later drift.
if [[ "$dry_run" -eq 0 && -n "$sentinel" && -f "$sentinel" && "$(cat "$sentinel")" == "${schema}:${target_uid}:${target_gid}" ]]; then
echo "[INFO] fix-ownership: ${target_uid}:${target_gid} (schema ${schema}) already applied, skipping"
exit 0
fi
# A sweep that could not chown everything must not be recorded as complete:
# the sentinel would make every later boot skip it and the entries would stay
# unreachable once services run unprivileged.
swept_clean=1
for path in "$@"; do
# An absent root is an incomplete sweep, not a finished one: /media/frigate
# is not in the image, so a boot before the volume is mounted would
# otherwise record success and the volume would never be swept once added.
if [[ ! -d "$path" ]]; then
swept_clean=0
echo "[WARN] fix-ownership: $path does not exist, skipping; will retry on next boot"
continue
fi
# find may fail mid-walk on a live volume (file deleted under it) or on a
# stale mount. Tolerate it rather than aborting under errexit, but never
# read a failed scan as "nothing to do": that would record the sweep as
# complete without having looked.
if ! count=$(find "$path" \( -not -uid "$target_uid" -o -not -gid "$target_gid" \) -printf '.' 2>/dev/null | wc -c); then
swept_clean=0
echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
continue
fi
if [[ "$count" -eq 0 ]]; then
echo "[INFO] fix-ownership: $path already owned by ${target_uid}:${target_gid}, nothing to do"
continue
fi
# find does not descend symlinks and chown -h retargets the link itself, so
# anything behind a symlinked directory is outside this sweep. Following
# them is not an option: a link could walk the chown out of the volume.
if [[ -n "$(find "$path" -type l -xtype d -print -quit 2>/dev/null)" ]]; then
echo "[WARN] fix-ownership: ${path} contains symlinked directories; ownership behind them is not managed and must be aligned by hand"
fi
echo "[WARN] fix-ownership: adjusting ownership of ${count} entries under ${path}; on large recordings volumes this can take a long time"
if [[ "$dry_run" -eq 1 ]]; then
echo "[INFO] fix-ownership: dry run, not changing ${path}"
continue
fi
find "$path" \( -not -uid "$target_uid" -o -not -gid "$target_gid" \) \
-exec chown -h "${target_uid}:${target_gid}" {} + || {
swept_clean=0
echo "[WARN] fix-ownership: some entries under ${path} could not be updated (deleted mid-sweep or chown denied); will retry on next mismatch"
}
done
# go2rtc (separate user) must be able to REACH its HomeKit state in /config.
# Write access is per-file, not per-directory: go2rtc's PatchConfig rewrites
# the first -config file via os.WriteFile (in-place truncate, no rename,
# verified against go2rtc v1.9.14 internal/app/config.go), and the file is
# always pre-created by setup_homekit_config before go2rtc starts, so
# O_CREATE never needs directory write. See header comment for why g+w is
# forbidden here.
if [[ "$dry_run" -eq 0 && -d /config ]]; then
chgrp frigate-data /config 2>/dev/null || true
chmod g+rx /config 2>/dev/null || true
fi
if [[ "$dry_run" -eq 0 && -n "$sentinel" && "$swept_clean" -eq 1 ]]; then
echo "${schema}:${target_uid}:${target_gid}" > "$sentinel" || \
echo "[WARN] fix-ownership: could not write ${sentinel}; the sweep will run again on next boot"
fi
@@ -3,12 +3,13 @@
import json
import os
import sys
from pathlib import Path
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.config.env import substitute_frigate_vars
from frigate.const import (
BIRDSEYE_PIPE,
LIBAVFORMAT_VERSION_MAJOR,
@@ -24,6 +25,15 @@ sys.path.remove("/opt/frigate")
yaml = YAML()
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
# read docker secret files as env vars too
if os.path.isdir("/run/secrets"):
for secret_file in os.listdir("/run/secrets"):
if secret_file.startswith("FRIGATE_"):
FRIGATE_ENV_VARS[secret_file] = (
Path(os.path.join("/run/secrets", secret_file)).read_text().strip()
)
config_file = find_config_file()
try:
@@ -37,20 +47,6 @@ try:
except FileNotFoundError:
config: dict[str, Any] = {}
# No validator runs here, so install environment_vars ourselves. FRIGATE_
# names only: anything else lands in os.environ, where the exec gate reads
# GO2RTC_ALLOW_ARBITRARY_EXEC.
config_env_vars = config.get("environment_vars")
apply_config_env_vars(
{
key: value
for key, value in config_env_vars.items()
if str(key).startswith("FRIGATE_")
}
if isinstance(config_env_vars, dict)
else {}
)
go2rtc_config: dict[str, Any] = config.get("go2rtc", {})
# Need to enable CORS for go2rtc so the frigate integration / card work automatically
@@ -117,7 +113,7 @@ for name in list(go2rtc_config.get("streams", {})):
if isinstance(stream, str):
try:
formatted_stream = substitute_frigate_vars(stream)
formatted_stream = stream.format(**FRIGATE_ENV_VARS)
if is_restricted_go2rtc_source(formatted_stream):
print(
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. "
@@ -126,7 +122,7 @@ for name in list(go2rtc_config.get("streams", {})):
del go2rtc_config["streams"][name]
continue
go2rtc_config["streams"][name] = formatted_stream
except ValueError as e:
except KeyError as e:
print(
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
)
@@ -136,7 +132,7 @@ for name in list(go2rtc_config.get("streams", {})):
filtered_streams = []
for i, stream_item in enumerate(stream):
try:
formatted_stream = substitute_frigate_vars(stream_item)
formatted_stream = stream_item.format(**FRIGATE_ENV_VARS)
if is_restricted_go2rtc_source(formatted_stream):
print(
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. "
@@ -145,7 +141,7 @@ for name in list(go2rtc_config.get("streams", {})):
continue
filtered_streams.append(formatted_stream)
except ValueError as e:
except KeyError as e:
print(
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
)
@@ -189,6 +185,3 @@ if config.get("birdseye", {}).get("restream", False):
# 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)
@@ -11,7 +11,6 @@ events {
http {
map_hash_bucket_size 256;
server_tokens off;
include mime.types;
default_type application/octet-stream;
@@ -63,7 +62,6 @@ http {
server {
include listen.conf;
include security_headers.conf;
# enable HTTP/2 for TLS connections to eliminate browser 6-connection limit
http2 on;
@@ -77,12 +75,6 @@ http {
vod_align_segments_to_key_frames on;
vod_manifest_segment_durations_mode accurate;
vod_ignore_edit_list on;
# short leading segments at each playlist start; sources start at
# the seek target, so the ladder applies to every seek. Only
# effective when clips declare real keyFrameDurations
vod_bootstrap_segment_durations 1000;
vod_bootstrap_segment_durations 2000;
vod_bootstrap_segment_durations 4000;
vod_segment_duration 10000;
# MPEG-TS settings (not used when fMP4 is enabled, kept for reference)
@@ -125,7 +117,6 @@ http {
secure_token $args;
secure_token_types application/vnd.apple.mpegurl;
include security_headers.conf;
add_header Cache-Control "no-store";
expires off;
@@ -142,7 +133,6 @@ http {
location /stream/ {
include auth_request.conf;
include security_headers.conf;
add_header Cache-Control "no-store";
expires off;
@@ -164,7 +154,6 @@ http {
}
expires 7d;
include security_headers.conf;
add_header Cache-Control "public";
autoindex on;
root /media/frigate;
@@ -257,7 +246,6 @@ http {
location /api/ {
include auth_request.conf;
include security_headers.conf;
add_header Cache-Control "no-store";
expires off;
proxy_pass http://frigate_api/;
@@ -324,34 +312,29 @@ http {
location / {
# do not require auth for static assets
include security_headers.conf;
add_header Cache-Control "no-store";
expires off;
location /assets/ {
access_log off;
expires 1y;
include security_headers.conf;
add_header Cache-Control "public";
}
location /fonts/ {
access_log off;
expires 1y;
include security_headers.conf;
add_header Cache-Control "public";
}
location /locales/ {
access_log off;
include security_headers.conf;
add_header Cache-Control "public";
}
location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ {
access_log off;
expires 1y;
include security_headers.conf;
add_header Cache-Control "public";
default_type application/json;
proxy_set_header Accept-Encoding "";
@@ -1,5 +0,0 @@
# Deliberately no X-Frame-Options or CSP frame-ancestors: HA's Webpage card and
# iframe panels frame Frigate cross-origin, and either would break them
# silently. Bind-mount this file to add your own.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
-45
View File
@@ -1,45 +0,0 @@
#!/bin/bash
# Ahead-of-time volume ownership migration for switching Frigate to non-root.
# Run from the host BEFORE enabling PUID/PGID or --user:
#
# ./fix-permissions.sh [--dry-run] <config_dir> <media_dir> [PUID] [PGID]
#
# Wraps the image's fix-ownership helper so there is exactly one
# implementation of the chown logic. Requires an image that contains the
# helper (any release that includes non-root support).
set -o errexit -o nounset -o pipefail
IMAGE="${FRIGATE_IMAGE:-ghcr.io/blakeblackshear/frigate:stable}"
dry_run_flag=""
if [[ "${1:-}" == "--dry-run" ]]; then
dry_run_flag="--dry-run"
shift
fi
if [[ $# -lt 2 ]]; then
echo "Usage: $0 [--dry-run] <config_dir> <media_dir> [PUID] [PGID]" >&2
exit 2
fi
config_dir="$1"
media_dir="$2"
puid="${3:-1000}"
pgid="${4:-1000}"
# The ids are interpolated into the container's bash -c source below, so
# anything but digits would be reparsed as shell rather than passed through
if ! [[ "$puid" =~ ^[0-9]+$ && "$pgid" =~ ^[0-9]+$ ]]; then
echo "[ERROR] PUID and PGID must be numeric, got '${puid}' and '${pgid}'" >&2
exit 2
fi
echo "[INFO] Using image ${IMAGE} (override with FRIGATE_IMAGE=...)"
# shellcheck disable=SC2086
docker run --rm \
-v "${config_dir}:/config" \
-v "${media_dir}:/media/frigate" \
--entrypoint bash \
"${IMAGE}" \
-c "command -v fix-ownership >/dev/null || { echo '[ERROR] this Frigate image predates non-root support; set FRIGATE_IMAGE to a release that includes it' >&2; exit 1; }; exec fix-ownership ${dry_run_flag} ${puid} ${pgid} /config /media/frigate"
File diff suppressed because it is too large. Load diff
+53 -102
View File
@@ -56,6 +56,17 @@ mqtt:
# 2 = exactly once
qos: 0
# Optional: Detectors configuration. Defaults to a single CPU detector
detectors:
# Required: name of the detector
detector_name:
# Required: type of the detector
# Frigate provides many types, see https://docs.frigate.video/configuration/object_detectors for more details (default: shown below)
# Additional detector types can also be plugged in.
# Detectors may require additional configuration.
# Refer to the Detectors configuration page for more information.
type: cpu
# Optional: Database configuration
database:
# The path to store the SQLite DB (default: shown below)
@@ -146,56 +157,44 @@ auth:
- front_door
- back_yard
# Optional: object detection models. Defaults to a single model on a CPU detector.
# Optional: model modifications
# NOTE: The default values are for the EdgeTPU detector.
# Other detectors will require the model config to be set.
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
# 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
# devices must use the same detector. Listing the same device more than once
# runs additional inference processes on it.
devices:
- edgetpu:pci:0
# Required: path to the model. Frigate+ models use plus://<model_id> (default: automatic based on detector)
path: /edgetpu_model.tflite
# Required: path to the labelmap (default: shown below)
labelmap_path: /labelmap.txt
# Required: Object detection model input width (default: shown below)
width: 320
# Required: Object detection model input height (default: shown below)
height: 320
# Required: Object detection model input colorspace
# Valid values are rgb, bgr, or yuv. (default: shown below)
input_pixel_format: rgb
# Required: Object detection model input tensor format
# Valid values are nhwc, nchw, hwnc, or hwcn (default: shown below)
input_tensor: nhwc
# Optional: Data type of the model input tensor
# Valid values are float, float_denorm, or int (default: shown below)
input_dtype: int
# Required: Object detection model architecture, used by detectors that support more
# than one model type (openvino, onnx, rknn, memryx, axengine, synaptics, and others)
# Valid values are ssd, yolox, yolonas, yolo-generic, rfdetr, dfine (default: shown below)
model_type: ssd
# Required: Label name modifications. These are merged into the standard labelmap.
labelmap:
2: vehicle
# Optional: Map of object labels to their attribute labels (default: depends on model)
attributes_map:
person:
- amazon
- face
car:
- amazon
- fedex
- license_plate
- ups
model:
# Required: path to the model. Frigate+ models use plus://<model_id> (default: automatic based on detector)
path: /edgetpu_model.tflite
# Required: path to the labelmap (default: shown below)
labelmap_path: /labelmap.txt
# Required: Object detection model input width (default: shown below)
width: 320
# Required: Object detection model input height (default: shown below)
height: 320
# Required: Object detection model input colorspace
# Valid values are rgb, bgr, or yuv. (default: shown below)
input_pixel_format: rgb
# Required: Object detection model input tensor format
# Valid values are nhwc, nchw, hwnc, or hwcn (default: shown below)
input_tensor: nhwc
# Optional: Data type of the model input tensor
# Valid values are float, float_denorm, or int (default: shown below)
input_dtype: int
# Required: Object detection model architecture, used by detectors that support more
# than one model type (openvino, onnx, rknn, memryx, axengine, synaptics, and others)
# Valid values are ssd, yolox, yolonas, yolo-generic, rfdetr, dfine (default: shown below)
model_type: ssd
# Required: Label name modifications. These are merged into the standard labelmap.
labelmap:
2: vehicle
# Optional: Map of object labels to their attribute labels (default: depends on model)
attributes_map:
person:
- amazon
- face
car:
- amazon
- fedex
- license_plate
- ups
# Optional: Audio Events Configuration
# NOTE: Can be overridden at the camera level
@@ -218,8 +217,6 @@ audio:
- fire_alarm
- speech
- yell
# Optional: Audio label name modifications. These are merged into the standard audio labelmap.
labelmap: {}
# Optional: Filters to configure detection.
filters:
# Label that matches label in listen config.
@@ -254,15 +251,11 @@ birdseye:
# Optional: Encoding quality of the mpeg1 feed (default: shown below)
# 1 is the highest quality, and 31 is the lowest. Lower quality feeds utilize less CPU resources.
quality: 8
# Optional: Activity types that include cameras in Birdseye (default: shown below)
# Multiple activity types can be listed at the same time.
# continuous: all cameras are included always
# motion: included if motion was detected within the inactivity threshold
# all_objects: included if a tracked object was present within the inactivity threshold
# alerts: included while an alert review item is in progress
# detections: included while a detection review item is in progress
modes:
- all_objects
# Optional: Mode of the view. Available options are: objects, motion, and continuous
# objects - cameras are included if they have had a tracked object within the last 30 seconds
# motion - cameras are included if motion was detected in the last 30 seconds
# continuous - all cameras are included always
mode: objects
# Optional: Threshold for camera activity to stop showing camera (default: shown below)
inactivity_threshold: 30
# Optional: Configure the birdseye layout
@@ -294,8 +287,6 @@ ffmpeg:
detect: -threads 2 -f rawvideo -pix_fmt yuv420p
# Optional: output args for record streams (default: shown below)
record: preset-record-generic
# Optional: output args for sub stream record streams (default: the record output args above)
# 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
@@ -315,10 +306,6 @@ detect:
width: 1280
# 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 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
@@ -650,42 +637,6 @@ record:
# For example, if the camera retain mode is "motion", the segments without motion are
# never stored, so setting the mode to "all" here won't bring them back.
mode: motion
# Optional: Sub stream recording settings
# Records a second, lower quality stream for quality selection during playback
# and extended low quality retention. Requires the record_sub role to be assigned
# to one of the camera's inputs.
sub:
# Optional: Enable sub stream recording (default: shown below)
# NOTE: Recording must also be enabled for sub stream recording to run.
enabled: False
# Optional: Continuous retention settings for sub stream recordings
continuous:
# Optional: Number of days to retain sub stream recordings regardless of tracked objects or motion (default: shown below)
days: 0
# Optional: Motion retention settings for sub stream recordings
motion:
# Optional: Number of days to retain sub stream recordings triggered by motion (default: shown below)
days: 0
# Optional: Retention settings for sub stream recordings of alerts
# NOTE: Pre and post capture windows are taken from the main alerts config above.
alerts:
# Required: Retention days (default: shown below)
days: 10
# Optional: Mode for retention. (default: shown below)
# all - save all sub stream recording segments for alerts regardless of activity
# motion - save all sub stream recording segments for alerts with any detected motion
# active_objects - save all sub stream recording segments for alerts with active/moving objects
mode: motion
# Optional: Retention settings for sub stream recordings of detections
# NOTE: Pre and post capture windows are taken from the main detections config above.
detections:
# Required: Retention days (default: shown below)
days: 10
# Optional: Mode for retention. (default: shown below)
# all - save all sub stream recording segments for detections regardless of activity
# motion - save all sub stream recording segments for detections with any detected motion
# active_objects - save all sub stream recording segments for detections with active/moving objects
mode: motion
# Optional: Configuration for the snapshots written to the clips directory for each tracked object
# Timestamp, bounding_box, crop and height settings are applied by default to API requests for snapshots.
@@ -937,7 +888,7 @@ cameras:
# Required: the path to the stream
# NOTE: path may include environment variables or docker secrets, which must begin with 'FRIGATE_' and be referenced in {}
- path: rtsp://viewer:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
# Required: list of roles for this stream. valid values are: audio,detect,record,record_sub
# Required: list of roles for this stream. valid values are: audio,detect,record
# NOTICE: In addition to assigning the audio, detect, and record roles
# they must also be enabled in the camera config.
roles:
+33 -68
View File
@@ -63,9 +63,15 @@ go2rtc:
### `environment_vars`
This section sets environment variables in the Frigate process for those unable to modify the environment of the container, like within Home Assistant OS. It's meant for process settings such as `LIBVA_DRIVER_NAME` or the TensorFlow thread counts below. Docker users should set environment variables in their `docker run` command (`-e LIBVA_DRIVER_NAME=i965`) or `docker-compose.yml` file (`environment:` section) instead. Values set here are stored in plain text in your config file, so credentials belong in `secrets.yaml`, Docker environment variables, or Docker secrets instead.
This section can be used to set environment variables for those unable to modify the environment of the container, like within Home Assistant OS. Docker users should set environment variables in their `docker run` command (`-e FRIGATE_MQTT_PASSWORD=secret`) or `docker-compose.yml` file (`environment:` section) instead. Note that values set here are stored in plain text in your config file, so if the goal is to keep credentials out of your configuration, use Docker environment variables or Docker secrets instead.
Names prefixed with `FRIGATE_` set here also take part in `{FRIGATE_VARIABLE_NAME}` substitution (see [below](#substitution-sources-and-precedence)), but `secrets.yaml` is the better home for them.
Variables prefixed with `FRIGATE_` can be referenced in config fields that support environment variable substitution (such as MQTT host and credentials, camera stream URLs, and ONVIF host and credentials) using the `{FRIGATE_VARIABLE_NAME}` syntax.
:::note
The `go2rtc` section is an exception. go2rtc runs as a separate process, so its stream definitions can only be substituted with variables that exist in the container's environment (set via Docker `-e`, the `environment:` section of `docker-compose.yml`, or Docker secrets). Variables defined in the `environment_vars` block above are not available to go2rtc streams. Home Assistant app users, who cannot set container environment variables, must instead put credentials directly in their go2rtc stream URLs.
:::
<ConfigTabs>
<TabItem value="ui">
@@ -74,17 +80,23 @@ Navigate to <NavPath path="Settings > System > Environment variables" /> to add
| Field | Description |
| ----------------- | --------------------------------------------------------- |
| **Variable name** | The environment variable name (e.g., `LIBVA_DRIVER_NAME`) |
| **Variable name** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) |
| **Value** | The value for the variable |
Names prefixed with `FRIGATE_` can also be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax.
Variables defined here can be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax.
</TabItem>
<TabItem value="yaml">
```yaml
environment_vars:
LIBVA_DRIVER_NAME: i965
FRIGATE_MQTT_USER: my_mqtt_user
FRIGATE_MQTT_PASSWORD: my_mqtt_password
mqtt:
host: "{FRIGATE_MQTT_HOST}"
user: "{FRIGATE_MQTT_USER}"
password: "{FRIGATE_MQTT_PASSWORD}"
```
</TabItem>
@@ -118,51 +130,6 @@ environment_vars:
</TabItem>
</ConfigTabs>
### `secrets.yaml`
A `secrets.yaml` file next to your `config.yml` is an additional source of `FRIGATE_` variables, for installs that can't set container environment variables or mount Docker secrets. It's a flat map of names to values, and it is never read or written by the Frigate UI:
```yaml
FRIGATE_CAM_USER: viewer
FRIGATE_CAM_PASS: "p@ss w0rd"
FRIGATE_MQTT_HOST: mqtt.internal.example
```
For Docker this is `/config/secrets.yaml` inside the container, so it lives in whatever host directory you mounted at `/config`. For the Home Assistant App it's `/addon_configs/<addon_directory>/secrets.yaml`, in the same folder as your `config.yml`; see [the App config directory](../config.md#accessing-app-config-dir) for the directory name for your variant.
Names must start with `FRIGATE_`, and nesting is not supported. `secrets.yaml` feeds `{FRIGATE_VARIABLE_NAME}` substitution, so the handful of variables Frigate reads straight from the process environment, such as `FRIGATE_JWT_SECRET`, still need a container environment variable or a Docker secret.
### Substitution sources and precedence
The same `{FRIGATE_VARIABLE_NAME}` placeholder resolves from four sources. When a name is defined in more than one, the higher one wins and a warning at startup names which source was used.
| Priority | Source | Where it's set | Who can use it |
| ----------- | --------------------- | -------------------------------------------------------------------------- | ------------------------------ |
| 1 (highest) | Docker secrets | Files in `/run/secrets`, or the directory named by `CREDENTIALS_DIRECTORY` | Docker, systemd |
| 2 | Container environment | `docker run -e`, the `environment:` section of `docker-compose.yml` | Docker |
| 3 | `secrets.yaml` | Next to `config.yml`, see above | Everyone, including the HA App |
| 4 (lowest) | `environment_vars` | The block in `config.yml` described above | Everyone, including the HA App |
For example, with this `secrets.yaml`:
```yaml
FRIGATE_MQTT_PASSWORD: from_secrets
```
and this `config.yml`:
```yaml
environment_vars:
FRIGATE_MQTT_PASSWORD: from_config
mqtt:
password: "{FRIGATE_MQTT_PASSWORD}"
```
the password resolves to `from_secrets`, and the log shows `FRIGATE_MQTT_PASSWORD is defined in more than one place, using the value from secrets.yaml`. Add `-e FRIGATE_MQTT_PASSWORD=from_env` to the container and it resolves to `from_env` instead.
Referencing a name that no source defines is a config validation error naming the field.
### `database`
Tracked object and recording information is managed in a sqlite database at `/config/frigate.db`. If that database is deleted, recordings will be orphaned and will need to be cleaned up manually. They also won't show up in the Media Browser within Home Assistant.
@@ -210,7 +177,7 @@ Custom models may also require different input tensor formats. The colorspace co
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detection models" /> and, on the model you want to change, open the **Custom Model** tab to configure the model path, dimensions, and input format.
Navigate to <NavPath path="Settings > System > Detectors and model" /> and open the **Custom Model** tab to configure the model path, dimensions, and input format.
| Field | Description |
| --------------------------------------------- | ------------------------------------ |
@@ -225,14 +192,12 @@ Navigate to <NavPath path="Settings > System > Detection models" /> and, on the
```yaml
# Optional: model config
models:
- devices:
- openvino:GPU
path: /path/to/model
width: 320
height: 320
input_tensor: "nhwc"
input_pixel_format: "bgr"
model:
path: /path/to/model
width: 320
height: 320
input_tensor: "nhwc"
input_pixel_format: "bgr"
```
</TabItem>
@@ -249,15 +214,15 @@ If the labelmap is customized then the labels used for alerts will need to be ad
The labelmap can be customized to your needs. A common reason to do this is to combine multiple object types that are easily confused when you don't need to be as granular such as car/truck. By default, truck is renamed to car because they are often confused. You cannot add new object types, but you can change the names of existing objects in the model.
```yaml
models:
- labelmap:
2: vehicle
3: vehicle
5: vehicle
7: vehicle
15: animal
16: animal
17: animal
model:
labelmap:
2: vehicle
3: vehicle
5: vehicle
7: vehicle
15: animal
16: animal
17: animal
```
Note that if you rename objects in the labelmap, you will also need to update your `objects -> track` list as well.
@@ -114,30 +114,6 @@ audio:
</TabItem>
</ConfigTabs>
#### Grouping Audio Labels
Related audio classes can be grouped under one label by mapping their numeric
class IDs to the same name. Add the grouped name to `listen` and use it for any
corresponding filter:
```yaml
audio:
listen:
- dogs
labelmap:
69: dogs # dog
70: dogs # bark
75: dogs # whimper_dog
filters:
dogs:
threshold: 0.8
```
Class IDs are zero-based indices in
[`audio-labelmap.txt`](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt),
so each ID is one less than the displayed file line number.
Audio label mappings are separate from the object detector's `model.labelmap`.
### Common Audio Labels
The labelmap includes hundreds of sound types. The labels below are the ones most users may find practical, grouped by what they're typically used for. Use the exact label string from the left column in your `listen` config, or search for the label in the Frigate UI directly.
+16 -23
View File
@@ -18,17 +18,13 @@ Each camera tile in Birdseye is composed from the frames of the stream assigned
## Birdseye Behavior
### Birdseye Activity Types
### Birdseye Modes
Birdseye offers independent activity types that control when cameras are shown. Multiple activity types can be listed together.
Birdseye offers different modes to customize which cameras show under which circumstances.
- **continuous:** The camera is always included
- **motion:** The camera is included when motion was detected within the last 30 seconds
- **all_objects:** The camera is included when a tracked object is present, active or stationary
- **alerts:** The camera is included while an alert review item is in progress
- **detections:** The camera is included while a detection review item is in progress
`alerts` and `detections` follow the review item's own lifetime, so the camera is removed as soon as the review item ends. Which objects qualify for each is set in [review configuration](./review.md).
- **continuous:** All cameras are always included
- **motion:** Cameras that have detected motion within the last 30 seconds are included
- **objects:** Cameras that have tracked an active object within the last 30 seconds are included
### Custom Birdseye Icon
@@ -43,29 +39,27 @@ To include a camera in Birdseye view only for specific circumstances, or exclude
**Global settings:** Navigate to <NavPath path="Settings > System > Birdseye" /> to configure the default Birdseye behavior for all cameras.
**Per-camera overrides:** Navigate to <NavPath path="Settings > Camera configuration > Birdseye" /> to override the activity types or disable Birdseye for a specific camera.
**Per-camera overrides:** Navigate to <NavPath path="Settings > Camera configuration > Birdseye" /> to override the mode or disable Birdseye for a specific camera.
| Field | Description |
| ---------------------- | ---------------------------------------------------------- |
| **Enable Birdseye** | Whether this camera appears in Birdseye view |
| **Activity types** | Conditions that determine when to show the camera |
| Field | Description |
| ------------------- | ------------------------------------------------------------- |
| **Enable Birdseye** | Whether this camera appears in Birdseye view |
| **Tracking mode** | When to show the camera: `continuous`, `motion`, or `objects` |
</TabItem>
<TabItem value="yaml">
```yaml {10-12,15-16}
```yaml {8-10,12-14}
# Include all cameras by default in Birdseye view
birdseye:
enabled: True
modes:
- continuous
mode: continuous
cameras:
front:
# Only include the "front" camera in Birdseye view when an alert is in progress
# Only include the "front" camera in Birdseye view when objects are detected
birdseye:
modes:
- alerts
mode: objects
back:
# Exclude the "back" camera from Birdseye view
birdseye:
@@ -77,7 +71,7 @@ cameras:
### Birdseye Inactivity
By default birdseye shows all cameras that have had the configured activity in the last 30 seconds. This threshold can be configured, and applies to the `motion` and `all_objects` activity types only.
By default birdseye shows all cameras that have had the configured activity in the last 30 seconds. This threshold can be configured.
<ConfigTabs>
<TabItem value="ui">
@@ -146,8 +140,7 @@ Navigate to <NavPath path="Settings > System > Birdseye" /> and in the **Camera
# Include all cameras by default in Birdseye view
birdseye:
enabled: True
modes:
- continuous
mode: continuous
cameras:
front:
+5 -6
View File
@@ -83,12 +83,11 @@ A camera is enabled by default but can be disabled by using `enabled: False`. Ca
Each role can only be assigned to one input per camera. The options for roles are as follows:
| Role | Description |
| ------------ | ------------------------------------------------------------------------------------------------------------ |
| `detect` | Main feed for object detection. [docs](object_detectors.md) |
| `record` | Saves segments of the video feed based on configuration settings. [docs](record.md) |
| `record_sub` | Saves segments of a second, lower quality stream with its own retention. [docs](record.md#sub-stream-recording) |
| `audio` | Feed for audio based detection. [docs](audio_detectors.md) |
| Role | Description |
| -------- | ----------------------------------------------------------------------------------- |
| `detect` | Main feed for object detection. [docs](object_detectors.md) |
| `record` | Saves segments of the video feed based on configuration settings. [docs](record.md) |
| `audio` | Feed for audio based detection. [docs](audio_detectors.md) |
<ConfigTabs>
<TabItem value="ui">
+22 -17
View File
@@ -100,7 +100,7 @@ VS Code supports JSON schemas for automatically validating configuration files.
## Environment Variable Substitution
Frigate supports the use of environment variables starting with `FRIGATE_` **only** where specifically indicated in the [reference config](./advanced/reference.md). See [substitution sources and precedence](./advanced/system.md#substitution-sources-and-precedence) for where those values can come from, including `secrets.yaml`. For example, the following values can be replaced at runtime by using environment variables:
Frigate supports the use of environment variables starting with `FRIGATE_` **only** where specifically indicated in the [reference config](./advanced/reference.md). For example, the following values can be replaced at runtime by using environment variables:
```yaml
mqtt:
@@ -154,7 +154,7 @@ Here are some common starter configuration examples. These can be configured thr
1. Navigate to <NavPath path="Settings > System > MQTT" /> and configure the MQTT connection to your Home Assistant Mosquitto broker
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)`
3. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
@@ -172,9 +172,10 @@ mqtt:
ffmpeg:
hwaccel_args: preset-rpi-64-h264
models:
- devices:
- edgetpu:usb
detectors:
coral:
type: edgetpu
device: usb
record:
enabled: True
@@ -232,7 +233,7 @@ cameras:
1. Navigate to <NavPath path="Settings > System > MQTT" /> and set **Enable MQTT** to off
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`
3. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
@@ -248,9 +249,10 @@ mqtt:
ffmpeg:
hwaccel_args: preset-vaapi
models:
- devices:
- edgetpu:usb
detectors:
coral:
type: edgetpu
device: usb
record:
enabled: True
@@ -308,8 +310,8 @@ cameras:
1. Navigate to <NavPath path="Settings > System > MQTT" /> and configure the connection to your MQTT broker
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`
3. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Intel GPU** from the **Hardware** dropdown
4. On the same model, open the **Custom Model** tab and configure the OpenVINO model path and settings
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `openvino` and **Device** `AUTO`
4. On the same page, in the **Custom Model** tab, configure the OpenVINO model path and settings
5. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
6. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
7. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
@@ -327,12 +329,15 @@ mqtt:
ffmpeg:
hwaccel_args: preset-vaapi
models:
- devices:
- openvino:AUTO
width: 300
height: 300
input_tensor: nhwc
detectors:
ov:
type: openvino
device: AUTO
model:
width: 300
height: 300
input_tensor: nhwc
input_pixel_format: bgr
path: /openvino-model/ssdlite_mobilenet_v2.xml
labelmap_path: /openvino-model/coco_91cl_bkgr.txt
@@ -106,5 +106,3 @@ Output arguments are passed to FFmpeg after your camera source and control how r
| preset-record-mjpeg | Record - MJPEG Cameras | Record an MJPEG stream | Restreaming the MJPEG stream is recommended instead |
| preset-record-jpeg | Record - JPEG Cameras | Record a live JPEG | Restreaming the live JPEG is recommended instead |
| preset-record-ubiquiti | Record - Ubiquiti Cameras | Record a Ubiquiti stream with audio | Handles Ubiquiti's non-standard audio format |
These presets apply to the `record` output args. If [sub stream recording](/configuration/record#sub-stream-recording) is enabled, the same args are used for the `record_sub` role unless `output_args.record_sub` is set, which accepts the same presets and manual args.
@@ -312,9 +312,8 @@ ffmpeg:
:::note
If running Frigate through Docker, map the relevant `/dev/video*` devices into
the container. Running in privileged mode also works but grants far more access
than needed. With Docker Compose add:
If running Frigate through Docker, you either need to run in privileged mode or
map the `/dev/video*` devices to Frigate. With Docker Compose add:
```yaml {4-5}
services:
+65 -112
View File
@@ -68,66 +68,12 @@ Frigate supports multiple different detectors that work on different types of ha
:::note
A single model can not be spread across different detector types (ex: OpenVINO and Coral EdgeTPU can not run the same model at the same time). Configuring more than one model, each on its own detector type, is supported.
Multiple detectors can not be mixed for object detection (ex: OpenVINO and Coral EdgeTPU can not be used for object detection at the same time).
This does not affect using hardware for accelerating other tasks such as [semantic search](./semantic_search.md)
:::
### Configuring models and hardware
Object detection is configured with a `models` list. Each entry describes one model and the hardware it runs on:
```yaml
models:
- devices:
- openvino:GPU
path: /config/model_cache/yolov9-s.onnx
model_type: yolo-generic
width: 320
height: 320
```
Each entry in `devices` is a detector type, optionally followed by a colon and a device for that detector, such as `edgetpu:pci:0`, `openvino:NPU`, or `tensorrt:0`. The per-detector sections below document the device values each one accepts. Listing several devices runs the model on all of them, and listing the **same** device more than once runs additional inference processes against it, which can improve throughput on hardware that keeps up with more than one stream:
```yaml
models:
- devices:
- openvino:GPU
- openvino:GPU
```
Coral EdgeTPU and MemryX accelerators can only be opened by one process, so those devices can not be repeated.
### Running more than one model
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: outdoor
path: plus://your-outdoor-model
devices:
- edgetpu:pci:0
- scene: indoor
path: /config/model_cache/indoor.onnx
model_type: yolo-generic
devices:
- openvino:GPU
cameras:
driveway:
detect:
scene: outdoor
...
hallway:
detect:
scene: indoor
...
```
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
Along with picking a detector for your hardware, you will choose a model's **input resolution** (such as `320x320` or `640x640`) and, for model families like YOLOv9, a **variant size** (`tiny`, `small`, etc.). Both affect the balance between accuracy and the inference time your hardware can sustain.
@@ -146,11 +92,11 @@ The best detection accuracy comes from a model trained on images that look like
# Officially Supported Detectors
Frigate provides a number of builtin detector types. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. Each of a model's devices runs in a dedicated process, and they pull from a common queue of detection requests from the cameras assigned to that model.
Frigate provides a number of builtin detector types. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
## Edge TPU Detector
The Edge TPU detector type runs TensorFlow Lite models utilizing the Google Coral delegate for hardware acceleration. To use it, prefix a model's device with `edgetpu`.
The Edge TPU detector type runs TensorFlow Lite models utilizing the Google Coral delegate for hardware acceleration. To configure an Edge TPU detector, set the `"type"` attribute to `"edgetpu"`.
The Edge TPU device can be specified using the `"device"` attribute according to the [Documentation for the TensorFlow Lite Python API](https://coral.ai/docs/edgetpu/multiple-edgetpu/#using-the-tensorflow-lite-python-api). If not set, the delegate will use the first device it finds.
@@ -165,15 +111,16 @@ See [common Edge TPU troubleshooting steps](/troubleshooting/edgetpu) if the Edg
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown.
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.
</TabItem>
<TabItem value="yaml">
```yaml
models:
- devices:
- edgetpu:usb
detectors:
coral:
type: edgetpu
device: usb
```
</TabItem>
@@ -184,16 +131,19 @@ models:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown and check each Coral the model should run on.
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `usb:0` and `usb:1` as the device for each.
</TabItem>
<TabItem value="yaml">
```yaml
models:
- devices:
- edgetpu:usb:0
- edgetpu:usb:1
detectors:
coral1:
type: edgetpu
device: usb:0
coral2:
type: edgetpu
device: usb:1
```
</TabItem>
@@ -206,15 +156,16 @@ _warning: may have [compatibility issues](https://github.com/blakeblackshear/fri
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detection models" /> and select the **Coral EdgeTPU** entry from the **Hardware** dropdown.
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then leave the device field empty.
</TabItem>
<TabItem value="yaml">
```yaml
models:
- devices:
- 'edgetpu:'
detectors:
coral:
type: edgetpu
device: ""
```
</TabItem>
@@ -225,15 +176,16 @@ models:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (PCIe)** from the **Hardware** dropdown.
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `pci`.
</TabItem>
<TabItem value="yaml">
```yaml
models:
- devices:
- edgetpu:pci
detectors:
coral:
type: edgetpu
device: pci
```
</TabItem>
@@ -244,16 +196,19 @@ models:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (PCIe)** from the **Hardware** dropdown and check each Coral the model should run on.
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `pci:0` and `pci:1` as the device for each.
</TabItem>
<TabItem value="yaml">
```yaml
models:
- devices:
- edgetpu:pci:0
- edgetpu:pci:1
detectors:
coral1:
type: edgetpu
device: pci:0
coral2:
type: edgetpu
device: pci:1
```
</TabItem>
@@ -264,16 +219,19 @@ models:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown. USB and PCIe Corals are listed as separate hardware, so mixing the two on one model has to be done in YAML.
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors with different device types (e.g., `usb` and `pci`).
</TabItem>
<TabItem value="yaml">
```yaml
models:
- devices:
- edgetpu:usb
- edgetpu:pci
detectors:
coral_usb:
type: edgetpu
device: usb
coral_pci:
type: edgetpu
device: pci
```
</TabItem>
@@ -315,7 +273,7 @@ Hailo8 supports all models in the Hailo Model Zoo that include HailoRT post-proc
## OpenVINO Detector
The OpenVINO detector type runs an OpenVINO IR model on AMD and Intel CPUs, Intel GPUs and Intel NPUs. To use it, prefix a model's device with `openvino`.
The OpenVINO detector type runs an OpenVINO IR model on AMD and Intel CPUs, Intel GPUs and Intel NPUs. To configure an OpenVINO detector, set the `"type"` attribute to `"openvino"`.
The OpenVINO device to be used is specified using the `"device"` attribute according to the naming conventions in the [Device Documentation](https://docs.openvino.ai/2025/openvino-workflow/running-inference/inference-devices-and-modes.html). The most common devices are `CPU`, `GPU`, or `NPU`.
@@ -328,10 +286,13 @@ OpenVINO is supported on 6th Gen Intel platforms (Skylake) and newer. It will al
When using many cameras one detector may not be enough to keep up. Multiple detectors can be defined assuming GPU resources are available. An example configuration would be:
```yaml
models:
- devices:
- openvino:GPU # or NPU
- openvino:GPU # or NPU
detectors:
ov_0:
type: openvino
device: GPU # or NPU
ov_1:
type: openvino
device: GPU # or NPU
```
:::
@@ -352,12 +313,6 @@ Intel NPUs cannot be used under Home Assistant OS, which does not include the NP
## Apple Silicon detector
:::warning
The network-based detectors (Deepstack and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
:::
The NPU in Apple Silicon can't be accessed from within a container, so the [Apple Silicon detector client](https://github.com/frigate-nvr/apple-silicon-detector) must first be setup. It is recommended to use the Frigate docker image with `-standard-arm64` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-standard-arm64`.
### Setup {#setup-apple-silicon}
@@ -498,10 +453,11 @@ If the correct build is used for your GPU then the GPU will be detected and used
When using many cameras one detector may not be enough to keep up. Multiple detectors can be defined assuming GPU resources are available. An example configuration would be:
```yaml
models:
- devices:
- onnx
- onnx
detectors:
onnx_0:
type: onnx
onnx_1:
type: onnx
```
:::
@@ -514,7 +470,7 @@ models:
## CPU Detector (not recommended)
The CPU detector type runs a TensorFlow Lite model utilizing the CPU without hardware acceleration. It is recommended to use a hardware accelerated detector type instead for better performance. To use it, set a model's device to `cpu`.
The CPU detector type runs a TensorFlow Lite model utilizing the CPU without hardware acceleration. It is recommended to use a hardware accelerated detector type instead for better performance. To configure a CPU based detector, set the `"type"` attribute to `"cpu"`.
:::danger
@@ -524,7 +480,7 @@ The CPU detector is not recommended for general use. If you do not have GPU or E
The number of threads used by the interpreter can be specified using the `"num_threads"` attribute, and defaults to `3.`
A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with the model's `path`.
A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with `model.path`.
### Configuration {#configuration-cpu}
@@ -534,12 +490,6 @@ When using CPU detectors, you can add one CPU detector per camera. Adding more d
## Deepstack / CodeProject.AI Server Detector
:::warning
The network-based detectors (Deepstack and the Apple Silicon client) are being reworked. Their extra options no longer have a place in the config, so only the endpoint carried in the device string is honored right now: Deepstack ignores `api_key` and `api_timeout`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
:::
The Deepstack / CodeProject.AI Server detector for Frigate allows you to integrate Deepstack and CodeProject.AI object detection capabilities into Frigate. CodeProject.AI and DeepStack are open-source AI platforms that can be run on various devices such as the Raspberry Pi, Nvidia Jetson, and other compatible hardware. It is important to note that the integration is performed over the network, so the inference times may not be as fast as native Frigate detectors, but it still provides an efficient and reliable solution for object detection and tracking.
### Setup {#setup-deepstack}
@@ -602,7 +552,7 @@ For detailed instructions on compiling models, refer to the [MemryX Compiler](ht
3. Depending on the model, the compiler may also generate a cropped post-processing network. If present, it will be named with the suffix `_post.onnx`.
4. Bind-mount the `.zip` file into the container and specify its path using the model's `path` in your config.
4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config.
5. Update `labelmap_path` to match your custom model's labels.
@@ -732,10 +682,13 @@ If no custom model is provided, the RKNN detector downloads a default model from
When using many cameras one detector may not be enough to keep up. Multiple detectors can be defined assuming NPU resources are available. An example configuration would be:
```yaml
models:
- devices:
- rknn:0
- rknn:0
detectors:
rknn_0:
type: rknn
num_cores: 0
rknn_1:
type: rknn
num_cores: 0
```
:::
-157
View File
@@ -275,163 +275,6 @@ record:
This configuration will retain recording segments that overlap with alerts and detections for 10 days. Because multiple tracked objects can reference the same recording segments, this avoids storing duplicate footage for overlapping tracked objects and reduces overall storage needs.
## Sub Stream Recording
In addition to the main recording stream, Frigate can record a second, lower quality stream for each camera. This serves two purposes:
- **Quality selection during playback**: A quality selector (`Auto`, `Original`, or `Low`) appears in History view for cameras with sub stream recording enabled. `Original` and `Low` play only that stream's recordings. Time ranges where the selected stream has no footage are skipped during playback, and the selector notes when the selected stream has no recordings at all in the viewed time range. With `Auto` (the default), playback prefers the original quality and automatically falls back to the low quality stream when the connection cannot keep up, or for time ranges where the original recordings have expired. The selector shows each stream's video codec and audio details beneath the options; footage recorded by older Frigate versions shows no details.
- **Extended retention**: Sub stream recordings have their own retention settings, fully independent of the main recordings. By giving the low quality recordings a longer retention period, you can keep weeks or months of low quality history using a fraction of the storage, and that history remains playable after the main recordings expire. Playback falls back to the low quality recordings automatically, and the timeline shows a muted treatment for time ranges where only low quality footage remains. Timeline previews are kept for as long as either stream still has recordings, so scrubbing works across the whole retained history.
### Configuring sub stream recording
Sub stream recording uses the `record_sub` input role. This role can be assigned to the same input as `detect`, so in the common case where detect already uses the camera's sub stream, no additional camera connection is needed. Like the main recording stream, sub stream segments are copied directly from the camera stream without re-encoding, so the recording quality is determined by the source stream.
The following examples keep 7 days of full quality continuous recordings and 60 days of low quality continuous recordings:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and select the camera.
- In **Camera inputs**, enable the **Record (Sub Stream)** role on the stream you want to record at low quality, commonly the same stream that has the **Detect** role. Only one stream may have this role, and it cannot be assigned to the same stream as the **Record** role.
Navigate to <NavPath path="Settings > Camera configuration > Recording" /> and select the camera.
- Set **Enable recording** to on
- Set **Continuous retention > Retention days** to `7`
- Set **Sub stream recording > Enable sub stream recording** to on
- Set **Sub stream recording > Sub stream continuous retention > Retention days** to `60`
The camera setup wizard also offers the **Record (Sub Stream)** role when assigning stream roles for a newly added camera.
</TabItem>
<TabItem value="yaml">
```yaml
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://camera/main
roles:
- record
- path: rtsp://camera/sub
roles:
- detect
- record_sub
record:
enabled: true
continuous:
days: 7
sub:
enabled: true
continuous:
days: 60
```
If your camera does not provide a suitable sub stream (or the sub stream is already used at a resolution you don't want to record), you can use a go2rtc transcode as the source for `record_sub` instead:
```yaml
go2rtc:
streams:
front_door: rtsp://camera/main
front_door_lq: ffmpeg:front_door#video=h264#width=854#hardware
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/front_door
input_args: preset-rtsp-restream
roles:
- detect
- record
- path: rtsp://127.0.0.1:8554/front_door_lq
input_args: preset-rtsp-restream
roles:
- record_sub
record:
enabled: true
continuous:
days: 7
sub:
enabled: true
continuous:
days: 60
```
</TabItem>
</ConfigTabs>
The `record.sub` config supports the same retention structure as the main recording config: `continuous`, `motion`, `alerts`, and `detections` each with their own `days` (and `mode` for alerts and detections). The pre-capture and post-capture windows for alerts and detections are taken from the main `record.alerts` and `record.detections` config. Extending `sub.alerts.days` or `sub.detections.days` beyond the main values also keeps those review items visible in the review timeline for the longer window, with playback falling back to the low quality stream once the main recordings expire.
:::note
Recording must be enabled (`record.enabled`) for sub stream recording to run, and Frigate will fail to start if `record.sub.enabled` is set without a `record_sub` role assigned to one of the camera's inputs.
:::
### How Auto picks a quality
`Auto` measures throughput on every segment download and compares it against the original stream's bitrate (computed from the recorded footage itself). Playback drops to the low quality stream when any of these happen:
- A freeze lasts 4 seconds (10 seconds when it starts within 2 seconds of a seek, since the seek target is rarely buffered), or freezes total 7 seconds within the last minute.
- 3 downloads in a row measure below the original bitrate plus 10%, dropping quality before a stall ever becomes visible.
- No first frame appears within 10 seconds, or loading fails outright.
Playback returns to full quality only when measured throughput exceeds the original bitrate by 50%, checked continuously while playing the low quality stream and again at each new hour. The asymmetric thresholds (1.1x to drop, 1.5x to return) keep a borderline connection from switching back and forth.
The most recent measurement is remembered on the device: a connection last measured below the original bitrate (or below 3 Mbps when the bitrate is not yet known) starts playback on the low quality stream so a first frame appears immediately, then upgrades within a few segments if the speed allows.
The quality selector shows which stream Auto is currently playing and why. A browser with Data Saver enabled stays on the low quality stream, a browser that cannot decode the original stream's codec (for example H.265 without HEVC support) plays the low quality stream for that camera, and pinning `Original` or `Low` bypasses Auto entirely.
### Sub stream output args
By default the sub stream is recorded with the same [output args](/configuration/ffmpeg_presets#output-args-presets) as the main recording stream, so it inherits any customization made to `ffmpeg.output_args.record`. Setting `ffmpeg.output_args.record_sub` gives the sub stream its own args instead. Like all `ffmpeg` config, this can be set globally or per camera.
The most common reason to set this is a pair of streams whose audio differs. Many cameras send AAC on the main stream but PCM on the sub stream, and PCM cannot be copied into an mp4 recording. Copying the main stream's audio avoids re-encoding audio that is already AAC, while the sub stream still needs to be transcoded:
```yaml
ffmpeg:
output_args:
# main stream audio is already AAC, so copy it
record: preset-record-generic-audio-copy
# sub stream audio is PCM, so transcode it to AAC
record_sub: preset-record-generic-audio-aac
```
Other reasons to set this are recording a sub stream whose codec needs a different preset than the main stream, such as `preset-record-mjpeg`, or forcing a matching audio sample rate across the two streams with manual args ending in `-c:a aac -ar 16000`.
:::warning
Avoid removing audio from only one of the two streams (for example with `-an`). When one stream has audio and the other does not, playback of time ranges that combine both qualities is silent, so stripping audio from the sub stream also silences the merged timeline.
:::
### Which stream do features use?
As a general rule, features that read recordings prefer the main stream and fall back to the sub stream for time ranges where the main recordings have expired. Analytics features use only the main stream.
| Feature | Stream used |
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Recording playback (History and Review) | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected manually |
| Tracking details and Explore clip playback | Main, falling back to sub where the main recordings have expired |
| Exports and clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
| Frames grabbed from a recording in History (download snapshot, submit frame to Frigate+) | Main preferred, sub fallback |
| Audio extraction (e.g., transcription) | Main preferred, sub fallback |
| Motion search | Main only |
| Review timeline motion data | Main only |
| Storage usage statistics | Both streams counted, and listed separately per camera |
This table covers only features that read recordings from disk. Tracked object snapshots and thumbnails (the images shown in Explore and sent with notifications, and the images submitted to Frigate+ from a tracked object) are captured live from the `detect` stream as the object is tracked, never from recordings, so sub stream recording does not affect them.
### Trade-offs
- Recording a second stream increases overall storage use. The increase is typically small relative to the main recordings, since the low quality stream is much smaller. Both streams are cached before being written to disk, so cache use goes up as well. See [the `/tmp/cache` area is separate](#the-tmpcache-area-is-separate) if you start seeing `No space left on device` errors after enabling it.
- The go2rtc transcode approach continuously encodes the low quality stream, which uses CPU or GPU resources. This cost only applies to the transcode path; recording the camera's native sub stream does not re-encode. See the [go2rtc hardware acceleration documentation](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) for accelerating the transcode.
- Many camera sub streams do not include audio. If the source stream has no audio, the low quality recordings will not have audio.
- **Matching video codecs and audio settings between the two streams gives the smoothest playback.** When playback combines both qualities on one timeline (the default `Auto` behavior: for example original quality during events with low quality in between, or low quality history after the original recordings expire) and the streams use different video codecs or audio settings, for example H.265 on the main stream and H.264 on the sub stream, or 16 kHz audio on one and 8 kHz on the other, playback still works: Frigate inserts a decoder reset at each quality transition, which can cause a barely-perceptible pause there. Configuring both streams in the camera's firmware to use the same video codec, audio codec, and sample rate makes transitions fully seamless, and a mismatched audio sample rate can also be corrected with [sub stream output args](#sub-stream-output-args). If one stream has audio and the other does not, combined time ranges play **without audio**; selecting a single quality with the playback selector always keeps that stream's audio.
## Can I have "continuous" recordings, but only at certain times?
Using Frigate UI, Home Assistant, or MQTT, cameras can be automated to only record in certain situations or at certain times.
+1 -1
View File
@@ -221,7 +221,7 @@ For security reasons, the `echo:`, `expr:`, and `exec:` stream sources are disab
If you attempt to use these sources in your configuration, the streams will be removed and an error message will be printed in the logs.
To enable these sources, you must set the environment variable `GO2RTC_ALLOW_ARBITRARY_EXEC=true`. This can be done in your Docker Compose file or container environment, or for Home Assistant App users with the `go2rtc_allow_arbitrary_exec` option in the App's configuration. The `environment_vars` section of the Frigate config can't enable it:
To enable these sources, you must set the environment variable `GO2RTC_ALLOW_ARBITRARY_EXEC=true`. This can be done in your Docker Compose file or container environment:
```yaml
environment:
+1 -30
View File
@@ -514,7 +514,7 @@ Generate a Frigate Docker Compose configuration based on your hardware and requi
services:
frigate:
container_name: frigate
# privileged: true # ONLY enable if your hardware requires it (see hardware-specific docs); prefer the device mappings below
privileged: true # this may not be necessary for all setups
restart: unless-stopped
stop_grace_period: 30s # allow enough time to shut down the various services
image: ghcr.io/blakeblackshear/frigate:stable
@@ -546,33 +546,6 @@ services:
</TabItem>
</Tabs>
### Recommended security options
Frigate does not need elevated container privileges for most setups. The
following hardens the container; add the `devices`/`group_add` entries your
hardware requires (see the hardware acceleration docs):
```yaml
services:
frigate:
...
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
```
:::note
`telemetry.stats.network_bandwidth` uses nethogs, which requires root with
NET_ADMIN/NET_RAW capabilities. If you enable that stat, omit `cap_drop: [ALL]`
or add `cap_add: [NET_ADMIN, NET_RAW]`.
Platforms that genuinely require `privileged: true` (MemryX, some QNAP setups)
are called out in their own sections and are unaffected by this guidance.
:::
**Docker CLI**
If you can't use Docker Compose, you can run the container with something similar to this:
@@ -639,8 +612,6 @@ Home Assistant OS users can install via the App repository.
5. Start the App
6. Use the _Open Web UI_ button to access the Frigate UI, then click in the _cog icon_ > _Configuration editor_ and configure Frigate to your liking
App users who can't set container environment variables can put `FRIGATE_` values in a `secrets.yaml` next to `config.yml` in `/addon_configs/<addon_directory>` instead. See [`secrets.yaml`](../configuration/advanced/system.md#secretsyaml).
There are several variants of the App available:
| App Variant | Description |
+21 -16
View File
@@ -204,8 +204,8 @@ You need to refer to **Configure hardware acceleration** above to enable the con
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Intel GPU** from the **Hardware** dropdown
2. On the same model, open the **Custom Model** tab and configure the model settings for OpenVINO:
1. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `OpenVINO` and **Device** `GPU`
2. On the same page, in the **Custom Model** tab, configure the model settings for OpenVINO:
| Field | Value |
| ---------------------------------------- | ------------------------------------------ |
@@ -222,12 +222,15 @@ You need to refer to **Configure hardware acceleration** above to enable the con
```yaml {3-6,9-15,20-21}
mqtt: ...
models: # <---- add models
- devices:
- openvino:GPU # <---- use the openvino detector on the GPU
# We will use the default MobileNet_v2 model from OpenVINO.
width: 300
height: 300
detectors: # <---- add detectors
ov:
type: openvino # <---- use openvino detector
device: GPU
# We will use the default MobileNet_v2 model from OpenVINO.
model:
width: 300
height: 300
input_tensor: nhwc
input_pixel_format: bgr
path: /openvino-model/ssdlite_mobilenet_v2.xml
@@ -270,7 +273,7 @@ services:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown.
Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`.
</TabItem>
<TabItem value="yaml">
@@ -278,9 +281,10 @@ Navigate to <NavPath path="Settings > System > Detection models" /> and select *
```yaml {3-6,11-12}
mqtt: ...
models: # <---- add models
- devices:
- edgetpu:usb
detectors: # <---- add detectors
coral:
type: edgetpu
device: usb
cameras:
name_of_your_camera:
@@ -317,9 +321,10 @@ If you are using YAML to configure Frigate instead of the UI, your configuration
mqtt:
enabled: False
models:
- devices:
- edgetpu:usb
detectors:
coral:
type: edgetpu
device: usb
cameras:
name_of_your_camera:
@@ -352,7 +357,7 @@ In order to review activity in the Frigate UI, recordings need to be enabled.
```yaml {16-17}
mqtt: ...
models: ...
detectors: ...
cameras:
name_of_your_camera:
+11 -14
View File
@@ -304,7 +304,7 @@ Topic with current state of notifications. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/status/<role>`
Publishes the current health status of each role that is enabled (`audio`, `detect`, `record`, `record_sub`). `record_sub` is only published for cameras with [sub stream recording](/configuration/record#sub-stream-recording) enabled, and is tracked separately from `record` so a healthy main stream can't hide a stalled sub stream. Possible values are:
Publishes the current health status of each role that is enabled (`audio`, `detect`, `record`). Possible values are:
- `online`: Stream is running and being processed
- `offline`: Stream is offline and is being restarted
@@ -553,25 +553,22 @@ must be enabled in the configuration.
Topic with current state of Birdseye for a camera. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/birdseye_modes/set`
### `frigate/<camera_name>/birdseye_mode/set`
Topic to set the Birdseye activity types for a camera. Send one uppercase activity type or combine multiple types with commas, for example `MOTION,ALERTS`.
Topic to set Birdseye mode for a camera. Birdseye offers different modes to customize under which circumstances the camera is shown.
_Note: Changing the value from `CONTINUOUS` to non-continuous activity types will take up to 30 seconds for
_Note: Changing the value from `CONTINUOUS` -> `MOTION | OBJECTS` will take up to 30 seconds for
the camera to be removed from the view._
| Command | Description |
| ------------- | ---------------------------------------------------------------- |
| `CONTINUOUS` | Always included |
| `MOTION` | Shown if motion was detected within the last 30 seconds |
| `ALL_OBJECTS` | Shown if a tracked object was present within the last 30 seconds |
| `ALERTS` | Shown while an alert review item is in progress |
| `DETECTIONS` | Shown while a detection review item is in progress |
| `NONE` | Never included |
| Command | Description |
| ------------ | ----------------------------------------------------------------- |
| `CONTINUOUS` | Always included |
| `MOTION` | Show when detected motion within the last 30 seconds are included |
| `OBJECTS` | Shown if an active object tracked within the last 30 seconds |
### `frigate/<camera_name>/birdseye_modes/state`
### `frigate/<camera_name>/birdseye_mode/state`
Topic with the current Birdseye activity types for a camera. Multiple enabled types are published as a comma-separated value in the order `CONTINUOUS`, `MOTION`, `ALL_OBJECTS`, `ALERTS`, `DETECTIONS`. `NONE` is published when no activity types are enabled.
Topic with current state of the Birdseye mode for a camera. Published values are `CONTINUOUS`, `MOTION`, `OBJECTS`.
### `frigate/<camera_name>/notifications/set`
+11 -11
View File
@@ -59,12 +59,13 @@ You can view all of your submitted images at [https://plus.frigate.video](https:
Once you have [requested your first model](../plus/first_model.md) and gotten your own model ID, it can be used with a special model path. No other information needs to be configured for Frigate+ models because it fetches the remaining config from Frigate+ automatically.
You can either choose the new model from the <NavPath path="Settings > System > Detection models" /> pane in the Frigate UI (on the **Frigate+** tab of the model you want to change), or set it on that model in your config:
You can either choose the new model from the <NavPath path="Settings > System > Detectors and model" /> pane in the Frigate UI (the **Frigate+ Model** tab), or manually set the model at the root level in your config:
```yaml
models:
- devices: ...
path: plus://<your_model_id>
detectors: ...
model:
path: plus://<your_model_id>
```
:::note
@@ -78,11 +79,10 @@ Models are downloaded into the `/config/model_cache` folder and only downloaded
If needed, you can override the labelmap for Frigate+ models. This is not recommended as renaming labels will break the Submit to Frigate+ feature if the labels are not available in Frigate+.
```yaml
models:
- devices: ...
path: plus://<your_model_id>
labelmap:
3: animal
4: animal
5: animal
model:
path: plus://<your_model_id>
labelmap:
3: animal
4: animal
5: animal
```
+5 -4
View File
@@ -30,15 +30,16 @@ Models available in Frigate+ can be used with a special model path. No other inf
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > Detection models" />. On the model you want to change, choose the **Frigate+** tab and select your new Frigate+ model from the **Available Frigate+ models** dropdown, then click **Save**. Restart Frigate to apply the change.
Navigate to <NavPath path="Settings > System > Detectors and model" />. In the **Detection Model** section, choose the **Frigate+** tab. Select your new Frigate+ model from the **Available Frigate+ models** dropdown, then click **Save**. Restart Frigate to apply the change.
</TabItem>
<TabItem value="yaml">
```yaml
models:
- devices: ...
path: plus://<your_model_id>
detectors: ...
model:
path: plus://<your_model_id>
```
:::tip
+1 -1
View File
@@ -131,7 +131,7 @@ The process was killed by the CPU for executing an unsupported instruction. Ther
<FaqItem id="onnx-invalidprotobuf" question="ONNX Runtime InvalidProtobuf / failed to load model">
ONNX Runtime could not parse the model file. The file exists but its contents are not a valid ONNX model, usually a corrupted or interrupted download in `model_cache`, or the wrong file pointed at by a model's `path`. Delete the cached model file so Frigate re-downloads it, and confirm the model's `path` points at an actual `.onnx` model. See [ONNX detector configuration](/configuration/object_detectors#onnx).
ONNX Runtime could not parse the model file. The file exists but its contents are not a valid ONNX model, usually a corrupted or interrupted download in `model_cache`, or the wrong file pointed at by `model.path`. Delete the cached model file so Frigate re-downloads it, and confirm `model.path` points at an actual `.onnx` model. See [ONNX detector configuration](/configuration/object_detectors#onnx).
</FaqItem>
+2 -2
View File
@@ -40,7 +40,7 @@ Deleting a group also clears any custom layout you saved for it.
## Rearranging a camera group layout
On desktop and tablet, each camera group has its own freely-arrangeable grid. Enter **Edit Layout** mode from the layout button in the lower-right corner: camera tiles gain a drag handle and corner resize handles. Drag a tile to reposition it and drag a corner to resize it (the aspect ratio is preserved). Exit edit mode to save. The layout is stored in your browser per device, so each device can have its own arrangement, and layouts can be exported to a file and imported on another device.
On desktop and tablet, each camera group has its own freely-arrangeable grid. Enter **Edit Layout** mode from the layout button in the lower-right corner: camera tiles gain a drag handle and corner resize handles. Drag a tile to reposition it and drag a corner to resize it (the aspect ratio is preserved). Exit edit mode to save. The layout is stored in your browser per device, so each device can have its own arrangement.
The default **All Cameras** dashboard is not manually arrangeable. It automatically sizes tiles based on each camera's aspect ratio (wide cameras span two columns, tall cameras span two rows).
@@ -68,7 +68,7 @@ For non-default groups, the context menu also exposes **Streaming Settings** for
- the **streaming method**: **No Streaming**, **Smart Streaming** (recommended), or **Continuous Streaming** (higher bandwidth), and
- **compatibility mode**, for devices that have trouble rendering the default player.
These settings are saved per group and per device in your browser, not in your config file, and can be exported to a file and imported on another device.
These settings are saved per group and per device in your browser, not in your config file.
## The single-camera view
+2 -1
View File
@@ -63,7 +63,8 @@ SYSTEM_NAV: dict[str, tuple[str, str]] = {
"environment_vars": ("System", "Environment variables"),
"telemetry": ("System", "Telemetry"),
"birdseye": ("System", "Birdseye"),
"models": ("System", "Detection models"),
"detectors": ("System", "Detectors and model"),
"model": ("System", "Detectors and model"),
}
# All known top-level config section keys
@@ -219,8 +219,6 @@ hardware:
- host: "/run/mxa_manager"
container: "/run/mxa_manager"
comment: "MemryX manager"
privileged: true
privilegedReason: "required by MemryX to reach the max-manager"
- id: "axera"
label: "AXERA Accelerator"
@@ -104,10 +104,6 @@ export interface DeviceConfig {
extraHosts?: string[];
/** Security options, e.g. ["apparmor=unconfined"] */
securityOpt?: string[];
/** Set only when this device type cannot work without full privileged mode */
privileged?: boolean;
/** Why privileged mode is required, rendered as an inline comment */
privilegedReason?: string;
/** Whether this device type needs the NVIDIA GPU config UI */
needsNvidiaConfig?: boolean;
}
@@ -131,10 +127,6 @@ export interface HardwareOption {
volumes?: VolumeMapping[];
/** Extra environment variables */
env?: Record<string, string>;
/** Set only when this hardware cannot work without full privileged mode */
privileged?: boolean;
/** Why privileged mode is required, rendered as an inline comment */
privilegedReason?: string;
}
/** Port definition */
@@ -1,7 +1,6 @@
import type {
DeviceConfig,
DeviceMapping,
HardwareOption,
VolumeMapping,
} from "../config/types";
import { hardwareMap } from "../config";
@@ -195,32 +194,13 @@ function buildExtraHosts(device: DeviceConfig): string[] {
}
function buildSecurityOpt(device: DeviceConfig): string[] {
// no-new-privileges is the baseline for every setup; device-specific entries
// are appended so only one security_opt key is ever emitted
if (!device.securityOpt?.length) return [];
return [
" security_opt:",
" - no-new-privileges:true",
...(device.securityOpt ?? []).map((s) => ` - ${s}`),
...device.securityOpt.map((s) => ` - ${s}`),
];
}
/**
* Emit privileged mode only for hardware that genuinely cannot work without it.
* Everything else gets device mappings, which grant far less access.
*/
function buildPrivileged(
device: DeviceConfig,
selectedHardware: HardwareOption[]
): string[] {
const requiring = [device, ...selectedHardware].filter((c) => c.privileged);
if (!requiring.length) return [];
const reasons = requiring
.map((c) => c.privilegedReason)
.filter((r): r is string => Boolean(r));
const comment = reasons.length ? ` # ${reasons.join("; ")}` : "";
return [` privileged: true${comment}`];
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -237,14 +217,11 @@ export function generateDockerCompose(input: GeneratorInput): string {
const hwVolumes: VolumeMapping[] = [];
const hwEnv: Record<string, string> = {};
const selectedHw: HardwareOption[] = [];
for (const hwId of input.selectedHardware) {
const hw = hardwareMap.get(hwId);
if (!hw) continue;
// Skip GPU device mapping for tensorrt images (it uses deploy instead)
if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue;
selectedHw.push(hw);
hwDevices.push(...(hw.devices ?? []));
hwVolumes.push(...(hw.volumes ?? []));
Object.assign(hwEnv, hw.env ?? {});
@@ -254,7 +231,7 @@ export function generateDockerCompose(input: GeneratorInput): string {
"services:",
" frigate:",
" container_name: frigate",
...buildPrivileged(device, selectedHw),
" privileged: true # This may not be necessary for all setups",
" restart: unless-stopped",
" stop_grace_period: 30s # Allow enough time to shut down the various services",
...buildImage(device),
+8 -276
View File
@@ -713,7 +713,7 @@ paths:
| `improve_contrast` | `ON`, `OFF` |
| `ptz_autotracker` | `ON`, `OFF` |
| `birdseye` | `ON`, `OFF` |
| `birdseye_modes` | `CONTINUOUS`, `MOTION`, `ALL_OBJECTS`, `ALERTS`, `DETECTIONS`, `NONE`, or a comma-separated combination |
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
| `motion_contour_area` | integer |
| `motion_threshold` | integer |
| `motion_mask` | `ON`, `OFF` |
@@ -803,7 +803,7 @@ paths:
| `improve_contrast` | `ON`, `OFF` |
| `ptz_autotracker` | `ON`, `OFF` |
| `birdseye` | `ON`, `OFF` |
| `birdseye_modes` | `CONTINUOUS`, `MOTION`, `ALL_OBJECTS`, `ALERTS`, `DETECTIONS`, `NONE`, or a comma-separated combination |
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
| `motion_contour_area` | integer |
| `motion_threshold` | integer |
| `motion_mask` | `ON`, `OFF` |
@@ -1476,12 +1476,10 @@ paths:
- Classification
summary: Get custom classification attributes
description: |-
**Access:** Any authenticated user.
**Access:** Admin role required.
Returns custom classification attributes for a given object type.
Only includes models with classification_type set to 'attribute'.
Callers without access to every camera only receive values that have been
recorded on the cameras they can access.
By default returns a flat sorted list of all attribute labels.
If group_by_model is true, returns attributes grouped by model name.
operationId: get_custom_attributes_classification_attributes_get
@@ -1512,8 +1510,8 @@ paths:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateUserAuth: []
x-required-role: any
- frigateAdminAuth: []
x-required-role: admin
/classification/{name}/train:
get:
tags:
@@ -2318,7 +2316,7 @@ paths:
- Review
summary: Generate Review Summary
description: |-
**Access:** Authenticated user with access to all cameras.
**Access:** Admin role required.
Use GenAI to summarize review items over a period of time.
operationId:
@@ -2349,8 +2347,8 @@ paths:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateUserAuth: []
x-required-role: all_cameras
- frigateAdminAuth: []
x-required-role: admin
/:
get:
tags:
@@ -2948,44 +2946,6 @@ paths:
- frigateUserAuth: []
x-required-role: any
description: '**Access:** Any authenticated user.'
/categorized_object_names:
get:
tags:
- App
summary: Get known object names by object type
description: |-
**Access:** Any authenticated user.
Returns the sub labels and attributes this install can attach,
grouped by object type. Unlike /sub_labels, which reflects what has already been
detected, this reads the config and model files, so it covers recognized face
names, named license plates, custom object classification categories, and the
detector attributes of tracked objects.
operationId: categorized_object_names_categorized_object_names_get
parameters:
- name: object_type
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: Object Type
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateUserAuth: []
x-required-role: any
/audio_labels:
get:
tags:
@@ -4012,49 +3972,6 @@ paths:
security:
- frigateAdminAuth: []
x-required-role: admin
/hardware/probe:
get:
tags:
- Hardware
summary: Probe Hardware
description: |-
**Access:** Admin role required.
Get the object detection hardware attached to this system.
Args:
refresh: Probe again instead of returning the cached result
Returns:
Every kind of detection hardware that was found
operationId: probe_hardware_hardware_probe_get
parameters:
- name: refresh
in: query
required: false
schema:
type: boolean
default: false
title: Refresh
responses:
'200':
description: Successful Response
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DetectionHardware'
title: Response Probe Hardware Hardware Probe Get
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/events:
get:
tags:
@@ -6067,65 +5984,6 @@ paths:
security:
- frigateUserAuth: []
x-required-role: camera
/vod/{camera_name}/{stream}/start/{start_ts}/end/{end_ts}:
get:
tags:
- Media
summary: Vod Ts Stream
description: |-
**Access:** Authenticated user with access to the referenced camera.
Returns an HLS playlist pinned to one stream type (main or sub) for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.
operationId:
vod_ts_stream_vod__camera_name___stream__start__start_ts__end__end_ts__get
parameters:
- name: camera_name
in: path
required: true
schema:
anyOf:
- type: string
- type: 'null'
title: Camera Name
- name: stream
in: path
required: true
schema:
$ref: '#/components/schemas/VodStreamPreference'
- name: start_ts
in: path
required: true
schema:
type: number
title: Start Ts
- name: end_ts
in: path
required: true
schema:
type: number
title: End Ts
- name: force_discontinuity
in: query
required: false
schema:
type: boolean
default: false
title: Force Discontinuity
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateUserAuth: []
x-required-role: camera
/events/{event_id}/snapshot.jpg:
get:
tags:
@@ -7064,63 +6922,6 @@ paths:
security:
- frigateUserAuth: []
x-required-role: camera
/{camera_name}/recordings/coverage:
get:
tags:
- Recordings
summary: Recordings Coverage
description: |-
**Access:** Authenticated user with access to the referenced camera.
Returns merged recording coverage spans plus codec compatibility.
codecs_compatible is false only when more than one known video codec
appears across the range's rows, the case where the merged vod route
degrades to a single-stream manifest.
operationId: recordings_coverage__camera_name__recordings_coverage_get
parameters:
- name: camera_name
in: path
required: true
schema:
anyOf:
- type: string
- type: 'null'
title: Camera Name
- name: after
in: query
required: true
schema:
type: number
title: After
- name: before
in: query
required: true
schema:
type: number
title: Before
- name: timelines
in: query
required: false
schema:
type: boolean
default: false
title: Timelines
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateUserAuth: []
x-required-role: camera
/{camera_name}/recordings:
get:
tags:
@@ -7887,46 +7688,6 @@ components:
required:
- ids
title: DeleteFaceImagesBody
DetectionHardware:
properties:
key:
type: string
title: Hardware key
description: Stable identifier for this kind of hardware.
detector:
type: string
title: Detector type
description: The detector that drives this hardware.
name:
type: string
title: Hardware name
description: Human readable name for this kind of hardware.
units:
items:
$ref: '#/components/schemas/HardwareUnit'
type: array
title: Units
description: Each physical piece of this hardware that was found.
count:
type: integer
title: Unit count
description: How many units were found.
unlimited:
type: boolean
title: Unlimited detectors
description: Whether this hardware can run more inference processes
than there are units.
type: object
required:
- key
- detector
- name
- units
- count
- unlimited
title: DetectionHardware
description: A kind of detection hardware, and every unit of it that was
found.
EventCreateResponse:
properties:
success:
@@ -8654,24 +8415,6 @@ components:
title: Detail
type: object
title: HTTPValidationError
HardwareUnit:
properties:
device:
type: string
title: Device string
description: The value to put in a model's devices list, for example
'edgetpu:pci:1'.
label:
type: string
title: Unit label
description: How to identify this unit among others of the same kind,
for example 'PCIe 1'.
type: object
required:
- device
- label
title: HardwareUnit
description: One physical piece of hardware.
Last24HoursReview:
properties:
reviewed_alert:
@@ -9162,17 +8905,6 @@ components:
- msg
- type
title: ValidationError
VodStreamPreference:
type: string
enum:
- main
- sub
title: VodStreamPreference
description: |-
Stream pin for the path-segment VOD route.
nginx-vod derives its mapping fetch URI from the playlist URL path
(query params are dropped), so the preference must be a path segment.
securitySchemes:
frigateAdminAuth:
type: apiKey
+29 -58
View File
@@ -71,7 +71,6 @@ from frigate.util.config import (
find_config_file,
redact_credential,
)
from frigate.util.object_names import get_categorized_object_names
from frigate.util.schema import get_config_schema
from frigate.util.services import (
get_nvidia_driver_info,
@@ -292,6 +291,10 @@ def config(request: Request):
config: dict[str, dict[str, Any]] = config_obj.model_dump(
mode="json", warnings="none", exclude_none=True
)
config["detectors"] = {
name: detector.model_dump(mode="json", warnings="none", exclude_none=True)
for name, detector in config_obj.detectors.items()
}
# remove environment_vars for non-admin users
if request.headers.get("remote-role") != "admin":
@@ -372,28 +375,31 @@ def config(request: Request):
config["go2rtc"]["streams"][stream_name] = cleaned
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
config["model"]["colormap"] = config_obj.model.colormap
config["model"]["all_attributes"] = config_obj.model.all_attributes
config["model"]["non_logo_attributes"] = config_obj.model.non_logo_attributes
for index, model in enumerate(config_obj.models):
model_dict = config["models"][index]
model_dict["colormap"] = model.colormap
model_dict["all_attributes"] = model.all_attributes
model_dict["non_logo_attributes"] = model.non_logo_attributes
model_dict["labelmap"] = model.merged_labelmap
if not config["plus"]["enabled"]:
continue
# Add model plus data if plus is enabled
model_dict["plus"] = None
if model.path:
model_json_path = FilePath(model.path).with_suffix(".json")
# Add model plus data if plus is enabled
if config["plus"]["enabled"]:
model_path = config.get("model", {}).get("path")
if model_path:
model_json_path = FilePath(model_path).with_suffix(".json")
try:
with open(model_json_path) as f:
model_dict["plus"] = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
pass
model_plus_data = json.load(f)
config["model"]["plus"] = model_plus_data
except FileNotFoundError:
config["model"]["plus"] = None
except json.JSONDecodeError:
config["model"]["plus"] = None
else:
config["model"]["plus"] = None
# use merged labelamp
for detector_config in config["detectors"].values():
detector_config["model"]["labelmap"] = (
request.app.frigate_config.model.merged_labelmap
)
return JSONResponse(content=config)
@@ -1307,41 +1313,9 @@ def get_sub_labels(
return JSONResponse(content=sub_labels)
@router.get(
"/categorized_object_names",
dependencies=[Depends(allow_any_authenticated())],
summary="Get known object names by object type",
description="""Returns the sub labels and attributes this install can attach,
grouped by object type. Unlike /sub_labels, which reflects what has already been
detected, this reads the config and model files, so it covers recognized face
names, named license plates, custom object classification categories, and the
detector attributes of tracked objects.""",
)
def categorized_object_names(
request: Request,
object_type: str | None = None,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
return JSONResponse(
content=get_categorized_object_names(
request.app.frigate_config, allowed_cameras, object_type
)
)
@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())])
def get_audio_labels(request: Request):
def get_audio_labels():
labels = load_labels("/audio-labelmap.txt", prefill=521)
# configured overrides group several audio classes under one label, and the
# detector merges them over the defaults at runtime. Offer them here too, or
# a grouped label could never be picked in the UI.
config: FrigateConfig = request.app.frigate_config
labels.update(config.audio.labelmap)
for camera in config.cameras.values():
labels.update(camera.audio.labelmap)
return JSONResponse(content=labels)
@@ -1363,14 +1337,11 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
modelList = models["list"]
config: FrigateConfig = request.app.frigate_config
primary_model = config.primary_model
# current model type
modelType = primary_model.model_type
modelType = request.app.frigate_config.model.model_type
# current detectorType for comparing to supportedDetectors
detectorType = config.devices_for_model(primary_model)[0].detector
detectorType = list(request.app.frigate_config.detectors.values())[0].type
validModels = []
+3 -34
View File
@@ -83,10 +83,8 @@ def require_admin_by_default():
"/nvinfo",
"/labels",
"/sub_labels",
"/categorized_object_names",
"/plus/models",
"/recognized_license_plates",
"/classification/attributes",
"/timeline",
"/timeline/hourly",
"/recordings/storage",
@@ -859,12 +857,9 @@ def login(request: Request, body: AppPostLoginBody):
user = body.user
password = body.password
remote_addr = get_remote_addr(request)
try:
db_user: User = User.get_by_id(user)
except DoesNotExist:
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
return JSONResponse(content={"message": "Login failed"}, status_code=401)
password_hash = db_user.password_hash
@@ -892,10 +887,6 @@ def login(request: Request, body: AppPostLoginBody):
request.app.frigate_config.auth.admin_first_time_login = False
return response
logger.warning(
f"Login failed for user '{user}' (invalid password) from {remote_addr}"
)
return JSONResponse(content={"message": "Login failed"}, status_code=401)
@@ -980,7 +971,6 @@ def delete_user(request: Request, username: str):
summary="Update user password",
description="Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity for non-admin users. Password must be at least 12 characters long. If user changes their own password, a new JWT cookie is automatically issued.",
)
@limiter.limit(limit_value=rateLimiter.get_limit)
async def update_password(
request: Request,
username: str,
@@ -994,11 +984,10 @@ async def update_password(
current_username = current_user.get("username")
current_role = current_user.get("role")
# Only admins may target another account. This has to cover every non-admin
# role rather than just viewer, since custom roles are arbitrary names
if current_role != "admin" and current_username != username:
# viewers can only change their own password
if current_role == "viewer" and current_username != username:
raise HTTPException(
status_code=403, detail="Users can only update their own password"
status_code=403, detail="Viewers can only update their own password"
)
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
@@ -1262,23 +1251,3 @@ async def get_allowed_cameras_for_filter(request: Request):
all_camera_names = set(request.app.frigate_config.cameras.keys())
roles_dict = request.app.frigate_config.auth.roles
return User.get_allowed_cameras(role, roles_dict, all_camera_names)
async def require_full_camera_access(
request: Request,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
"""Dependency for endpoints returning data that spans every camera.
Some responses cannot be meaningfully scoped to a subset of cameras, so
rather than filter them the endpoint is limited to callers who can already
see every camera. Admin and viewer always qualify; a custom role qualifies
only when its camera list covers all configured cameras.
"""
all_camera_names = set(request.app.frigate_config.cameras.keys())
if not all_camera_names.issubset(allowed_cameras):
raise HTTPException(
status_code=403,
detail="Access to all cameras is required for this endpoint",
)
+3 -40
View File
@@ -33,7 +33,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.config.env import UnknownVariableError, substitute_frigate_vars
from frigate.config.env import substitute_frigate_vars
from frigate.models import User
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
@@ -166,7 +166,7 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
if src:
try:
resolved_src = substitute_frigate_vars(src)
except UnknownVariableError:
except KeyError:
resolved_src = src
if is_restricted_go2rtc_source(resolved_src):
@@ -651,32 +651,6 @@ async def _connect_onvif_camera(
raise first_error
def _supports_continuous_pan_tilt(nodes) -> bool:
"""Whether any PTZ node advertises continuous pan/tilt velocity.
The web UI's directional controls issue ContinuousMove with a PanTilt
velocity, so continuous pan/tilt is what makes those controls usable. This
is intentionally narrower than ptz_supported, which is true for any device
exposing the ONVIF PTZ service - including zoom/focus-only varifocal lenses.
"""
for node in nodes or []:
spaces = getattr(node, "SupportedPTZSpaces", None) or (
node.get("SupportedPTZSpaces") if isinstance(node, dict) else None
)
if spaces is None:
continue
continuous = getattr(spaces, "ContinuousPanTiltVelocitySpace", None) or (
spaces.get("ContinuousPanTiltVelocitySpace")
if isinstance(spaces, dict)
else None
)
if continuous:
return True
return False
@router.get(
"/onvif/probe",
dependencies=[Depends(require_role(["admin"]))],
@@ -834,7 +808,6 @@ async def onvif_probe(
# Check PTZ support and capabilities
ptz_supported = False
pan_tilt_supported = False
presets_count = 0
autotrack_supported = False
@@ -868,15 +841,6 @@ async def onvif_probe(
logger.debug(f"Failed to get presets: {e}")
presets_count = 0
# Check for real (continuous) pan/tilt, which the UI controls need
if ptz_supported:
try:
nodes = await ptz_service.GetNodes()
pan_tilt_supported = _supports_continuous_pan_tilt(nodes)
logger.debug(f"Continuous pan/tilt supported: {pan_tilt_supported}")
except Exception as e:
logger.debug(f"Failed to read PTZ nodes for pan/tilt support: {e}")
# Check for autotracking support - requires both FOV relative movement and MoveStatus
if ptz_supported and first_profile_token and ptz_config_token:
# First check for FOV relative movement support
@@ -996,7 +960,6 @@ async def onvif_probe(
"firmware_version": device_info["firmware_version"],
"profiles_count": profiles_count,
"ptz_supported": ptz_supported,
"pan_tilt_supported": pan_tilt_supported,
"presets_count": presets_count,
"autotrack_supported": autotrack_supported,
}
@@ -1386,7 +1349,7 @@ def camera_set(
| `improve_contrast` | `ON`, `OFF` |
| `ptz_autotracker` | `ON`, `OFF` |
| `birdseye` | `ON`, `OFF` |
| `birdseye_modes` | `CONTINUOUS`, `MOTION`, `ALL_OBJECTS`, `ALERTS`, `DETECTIONS`, `NONE`, or a comma-separated combination |
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
| `motion_contour_area` | integer |
| `motion_threshold` | integer |
| `motion_mask` | `ON`, `OFF` |
+4 -27
View File
@@ -50,7 +50,6 @@ from frigate.jobs.vlm_watch import (
stop_vlm_watch_job,
)
from frigate.models import Event
from frigate.util.object_names import get_categorized_object_names
logger = logging.getLogger(__name__)
@@ -540,11 +539,6 @@ async def execute_tool(
if tool_name == "search_objects":
return await _execute_search_objects(request, arguments, allowed_cameras)
if tool_name == "get_categorized_object_names":
return JSONResponse(
content=_execute_get_categorized_object_names(request, allowed_cameras)
)
if tool_name == "find_similar_objects":
result = await _execute_find_similar_objects(
request, arguments, allowed_cameras
@@ -597,7 +591,7 @@ async def _execute_get_live_context(
try:
frame_processor = request.app.detected_frames_processor
camera_state = frame_processor.get_camera_state(camera)
camera_state = frame_processor.camera_states.get(camera)
if camera_state is None:
return {
@@ -661,7 +655,7 @@ async def _get_live_frame_image_url(
return None
try:
frame_processor = request.app.detected_frames_processor
if frame_processor.get_camera_state(camera) is None:
if camera not in frame_processor.camera_states:
return None
frame = frame_processor.get_current_frame(camera, {})
if frame is None:
@@ -723,21 +717,6 @@ async def _execute_set_camera_state(
return {"success": True, "camera": camera, "feature": feature, "value": value}
def _execute_get_categorized_object_names(
request: Request,
allowed_cameras: list[str],
) -> dict[str, Any]:
names = get_categorized_object_names(request.app.frigate_config, allowed_cameras)
if not names:
return {
"names": {},
"message": "No names configured; search by label or semantic_query.",
}
return {"names": names}
async def _execute_tool_internal(
tool_name: str,
arguments: dict[str, Any],
@@ -762,8 +741,6 @@ async def _execute_tool_internal(
except (json.JSONDecodeError, AttributeError) as e:
logger.warning(f"Failed to extract tool result: {e}")
return {"error": "Failed to parse tool result"}
elif tool_name == "get_categorized_object_names":
return _execute_get_categorized_object_names(request, allowed_cameras)
elif tool_name == "find_similar_objects":
return await _execute_find_similar_objects(request, arguments, allowed_cameras)
elif tool_name == "set_camera_state":
@@ -796,8 +773,8 @@ async def _execute_tool_internal(
else:
logger.error(
"Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, "
"get_categorized_object_names, get_live_context, start_camera_watch, stop_camera_watch, "
"get_profile_status, get_recap. Arguments received: %s",
"get_live_context, start_camera_watch, stop_camera_watch, get_profile_status, get_recap. "
"Arguments received: %s",
tool_name,
json.dumps(arguments),
)
+3 -96
View File
@@ -11,14 +11,10 @@ from typing import Any
import cv2
from fastapi import APIRouter, Depends, Request, UploadFile
from fastapi.responses import JSONResponse
from peewee import DoesNotExist, fn
from peewee import DoesNotExist
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import (
allow_any_authenticated,
get_allowed_cameras_for_filter,
require_role,
)
from frigate.api.auth import require_role
from frigate.api.defs.request.classification_body import (
AudioTranscriptionBody,
DeleteFaceImagesBody,
@@ -743,81 +739,18 @@ def get_classification_dataset(name: str):
)
def get_observed_attributes(
model_attributes: dict[str, list[str]],
object_labels: set[str],
allowed_cameras: list[str],
) -> dict[str, set[str]]:
"""Get the attribute values recorded on the given cameras.
Args:
model_attributes: Labels each attribute model can emit, keyed by model name
object_labels: Object types those models run on
allowed_cameras: Cameras the caller has access to
Returns:
Values seen for each model, keyed by model name
"""
if not model_attributes or not object_labels or not allowed_cameras:
return {}
model_names = list(model_attributes.keys())
query = (
Event.select(
*[
fn.json_extract(Event.data, f'$."{model_name}"')
for model_name in model_names
]
)
.where(
(Event.camera << allowed_cameras) & (Event.label << sorted(object_labels))
)
.distinct()
.tuples()
)
targets = {
model_name: set(attributes)
for model_name, attributes in model_attributes.items()
}
observed: dict[str, set[str]] = {model_name: set() for model_name in model_names}
for row in query.iterator():
found = False
for model_name, value in zip(model_names, row):
if isinstance(value, str) and value not in observed[model_name]:
observed[model_name].add(value)
found = True
if found and all(
observed[model_name] >= targets[model_name] for model_name in model_names
):
break
return observed
@router.get(
"/classification/attributes",
dependencies=[Depends(allow_any_authenticated())],
summary="Get custom classification attributes",
description="""Returns custom classification attributes for a given object type.
Only includes models with classification_type set to 'attribute'.
Callers without access to every camera only receive values that have been
recorded on the cameras they can access.
By default returns a flat sorted list of all attribute labels.
If group_by_model is true, returns attributes grouped by model name.""",
)
def get_custom_attributes(
request: Request,
object_type: str = None,
group_by_model: bool = False,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
request: Request, object_type: str = None, group_by_model: bool = False
):
models_with_attributes = {}
objects_by_model = {}
for (
model_key,
@@ -848,32 +781,6 @@ def get_custom_attributes(
if attributes:
model_name = model_config.name or model_key
models_with_attributes[model_name] = sorted(attributes)
objects_by_model[model_name] = model_objects
# the dataset holds every label a model can emit, including ones never
# applied to an event, so callers without full camera access are limited to
# the values actually recorded on the cameras they can see
all_cameras = set(request.app.frigate_config.cameras.keys())
if models_with_attributes and not all_cameras.issubset(allowed_cameras):
observed = get_observed_attributes(
models_with_attributes,
set().union(*objects_by_model.values()),
allowed_cameras,
)
models_with_attributes = {
model_name: [
attribute
for attribute in attributes
if attribute in observed.get(model_name, set())
]
for model_name, attributes in models_with_attributes.items()
}
models_with_attributes = {
model_name: attributes
for model_name, attributes in models_with_attributes.items()
if attributes
}
if group_by_model:
return JSONResponse(content=models_with_attributes)
-1
View File
@@ -8,7 +8,6 @@ class Tags(Enum):
chat = "Chat"
events = "Events"
export = "Export"
hardware = "Hardware"
classification = "Classification"
logs = "Logs"
media = "Media"
+2 -2
View File
@@ -1313,7 +1313,7 @@ async def set_sub_label(
if request.app.detected_frames_processor:
tracked_obj: TrackedObject = None
for state in request.app.detected_frames_processor.get_camera_states():
for state in request.app.detected_frames_processor.camera_states.values():
tracked_obj = state.tracked_objects.get(event_id)
if tracked_obj is not None:
@@ -1372,7 +1372,7 @@ async def set_plate(
if request.app.detected_frames_processor:
tracked_obj: TrackedObject = None
for state in request.app.detected_frames_processor.get_camera_states():
for state in request.app.detected_frames_processor.camera_states.values():
tracked_obj = state.tracked_objects.get(event_id)
if tracked_obj is not None:
-2
View File
@@ -21,7 +21,6 @@ from frigate.api import (
debug_replay,
event,
export,
hardware,
media,
motion_search,
notification,
@@ -146,7 +145,6 @@ def create_fastapi_app(
app.include_router(preview.router)
app.include_router(notification.router)
app.include_router(export.router)
app.include_router(hardware.router)
app.include_router(event.router)
app.include_router(media.router)
app.include_router(motion_search.router)
-30
View File
@@ -1,30 +0,0 @@
"""Hardware discovery APIs."""
import logging
from fastapi import APIRouter, Depends
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
from frigate.detectors.hardware import DetectionHardware, hardware_prober
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.hardware])
@router.get(
"/hardware/probe",
response_model=list[DetectionHardware],
dependencies=[Depends(require_role(["admin"]))],
)
def probe_hardware(refresh: bool = False) -> list[DetectionHardware]:
"""Get the object detection hardware attached to this system.
Args:
refresh: Probe again instead of returning the cached result
Returns:
Every kind of detection hardware that was found
"""
return hardware_prober.probe(refresh=refresh)
+174 -339
View File
@@ -6,13 +6,10 @@ import logging
import math
import os
import subprocess as sp
import tempfile
import time
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from enum import Enum
from pathlib import Path as FilePath
from typing import IO, Any
from typing import Any
from urllib.parse import unquote
import cv2
@@ -42,14 +39,12 @@ from frigate.config.camera.snapshots import SnapshotsConfig
from frigate.const import (
CACHE_DIR,
INSTALL_DIR,
MAX_SEGMENT_DURATION,
PREVIEW_FRAME_TYPE,
STREAM_TYPE_MAIN,
STREAM_TYPE_SUB,
)
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
from frigate.output.preview import get_most_recent_preview_frame
from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.ffmpeg import terminate_ffmpeg_stream
from frigate.util.file import (
get_event_snapshot_bytes,
get_event_snapshot_path,
@@ -57,40 +52,12 @@ from frigate.util.file import (
load_event_snapshot_image,
)
from frigate.util.image import get_image_from_recording, get_image_quality_params
from frigate.util.media import get_keyframe_before
from frigate.util.object import create_empty_regions_grid
from frigate.util.recording_coverage import (
build_spans,
null_audio_glitches,
plan_clip,
resolve_coverage,
stream_has_audio,
)
logger = logging.getLogger(__name__)
# must match the patched MAX_CLIPS in docker/main/build_nginx.sh; a
# normal hour needs ~360, one clip per recording file
NGINX_VOD_MAX_CLIPS = 1080
# tail of ffmpeg's stderr kept for the clip download failure log
CLIP_STDERR_LOG_BYTES = 8192
# how long a drained clip download waits for ffmpeg to exit on its own
CLIP_FFMPEG_EXIT_TIMEOUT = 10
class VodStreamPreference(str, Enum):
"""Stream pin for the path-segment VOD route.
nginx-vod derives its mapping fetch URI from the playlist URL path
(query params are dropped), so the preference must be a path segment.
"""
main = STREAM_TYPE_MAIN
sub = STREAM_TYPE_SUB
router = APIRouter(tags=[Tags.media])
@@ -352,7 +319,7 @@ async def get_snapshot_from_recording(
& (frame_time <= Recordings.end_time)
)
.where(Recordings.camera == camera_name)
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
.order_by(Recordings.start_time.desc())
.limit(1)
.get()
)
@@ -371,7 +338,7 @@ async def get_snapshot_from_recording(
& (frame_time <= Recordings.end_time)
)
.where(Recordings.camera == camera_name)
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
.order_by(Recordings.start_time.desc())
.limit(1)
.get()
)
@@ -431,7 +398,7 @@ async def submit_recording_snapshot_to_plus(
(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())
.order_by(Recordings.start_time.desc())
.limit(1)
)
@@ -474,53 +441,6 @@ async def submit_recording_snapshot_to_plus(
)
def _read_stderr_tail(stderr_file: IO[bytes]) -> str:
"""Read back the last CLIP_STDERR_LOG_BYTES of a captured stderr file."""
stderr_file.seek(0, os.SEEK_END)
stderr_file.seek(max(0, stderr_file.tell() - CLIP_STDERR_LOG_BYTES))
return stderr_file.read().decode("utf-8", "replace")
def _run_clip_download(ffmpeg_cmd: list[str], file_path: str) -> Iterator[bytes]:
"""Stream an ffmpeg concat remux to the client, always cleaning up after it."""
stderr_file = None
ffmpeg = None
try:
stderr_file = tempfile.TemporaryFile()
ffmpeg = sp.Popen(ffmpeg_cmd, stdout=sp.PIPE, stderr=stderr_file)
while True:
data = ffmpeg.stdout.read(8192)
if not data:
break
yield data
try:
# wait rather than signal, so the real exit code survives
ffmpeg.wait(timeout=CLIP_FFMPEG_EXIT_TIMEOUT)
except sp.TimeoutExpired:
pass
finally:
if ffmpeg is not None:
# read before terminating: a None here is our teardown, not a failure
exit_code = ffmpeg.poll()
terminate_ffmpeg_stream(ffmpeg)
if exit_code:
logger.error(
"Failed to generate clip, ffmpeg logs: %s",
_read_stderr_tail(stderr_file),
)
if stderr_file is not None:
stderr_file.close()
FilePath(file_path).unlink(missing_ok=True)
@router.get(
"/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4",
dependencies=[Depends(require_camera_access)],
@@ -532,29 +452,40 @@ async def recording_clip(
start_ts: float,
end_ts: float,
):
def get_clip_query(stream_type: str):
return (
Recordings.select(
Recordings.path,
Recordings.start_time,
Recordings.end_time,
)
.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())
def run_download(ffmpeg_cmd: list[str], file_path: str):
with sp.Popen(
ffmpeg_cmd,
stderr=sp.PIPE,
stdout=sp.PIPE,
text=False,
) as ffmpeg:
while True:
data = ffmpeg.stdout.read(8192)
if data is not None and len(data) > 0:
yield data
else:
if ffmpeg.returncode and ffmpeg.returncode != 0:
logger.error(
f"Failed to generate clip, ffmpeg logs: {ffmpeg.stderr.read()}"
)
else:
FilePath(file_path).unlink(missing_ok=True)
break
recordings = (
Recordings.select(
Recordings.path,
Recordings.start_time,
Recordings.end_time,
)
# never mix streams in one concat; use main when available and
# fall back to sub for expired-main history
recordings = get_clip_query(STREAM_TYPE_MAIN)
if recordings.count() == 0:
recordings = get_clip_query(STREAM_TYPE_SUB)
.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)
.order_by(Recordings.start_time.asc())
)
if recordings.count() == 0:
return JSONResponse(
@@ -565,9 +496,7 @@ async def recording_clip(
status_code=400,
)
file_name = sanitize_filename(
f"playlist_{camera_name}_{start_ts}-{end_ts}_{os.urandom(4).hex()}.txt"
)
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
file_path = os.path.join(CACHE_DIR, file_name)
with open(file_path, "w") as file:
clip: Recordings
@@ -615,195 +544,11 @@ async def recording_clip(
]
return StreamingResponse(
_run_clip_download(ffmpeg_cmd, file_path),
run_download(ffmpeg_cmd, file_path),
media_type="video/mp4",
)
def _build_vod_clip(
row: Any, start: float, end: float
) -> tuple[dict[str, Any], int] | None:
"""Build one nginx-vod clip dict + duration (ms) for a recording row trimmed to [start, end).
Realization comes entirely from the shared plan_clip, so the coverage
endpoint's realized timelines match this manifest by construction.
"""
plan = plan_clip(row, start, end)
if plan.skipped:
return None
clip: dict[str, Any] = {"type": "source", "path": row.path}
if plan.clip_from_ms is not None:
clip["clipFrom"] = plan.clip_from_ms
if plan.key_frame_durations is not None:
# real gaps enable keyframe-aligned sub-file segments (bootstrap
# ladder); the whole-clip fallback keeps one segment per file,
# the only safe cut without an index
if plan.first_key_frame_offset_ms > 0:
clip["firstKeyFrameOffset"] = plan.first_key_frame_offset_ms
clip["keyFrameDurations"] = plan.key_frame_durations
else:
clip["keyFrameDurations"] = [plan.duration_ms]
logger.debug(
"VOD: added clip %s duration_ms=%s clipFrom=%s",
row.path,
plan.duration_ms,
clip.get("clipFrom"),
)
return clip, plan.duration_ms
async def _vod_response(
camera_name: str,
start_ts: float,
end_ts: float,
force_discontinuity: bool = False,
stream_preference: str | None = None,
) -> JSONResponse:
"""Build an nginx-vod mapping JSON for a camera over a timestamp range.
Always a single-sequence mapping; quality selection happens in the
frontend by choosing between this route and the stream-pinned routes.
Args:
camera_name: The camera to build the mapping for
start_ts: Range start as a unix timestamp
end_ts: Range end as a unix timestamp
force_discontinuity: Emit HLS discontinuity markers between clips
stream_preference: Pin the manifest to one stream type ("main" or
"sub"), serving only that stream's recordings
"""
logger.debug(
"VOD: Generating VOD for %s from %s to %s with force_discontinuity=%s",
camera_name,
start_ts,
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(intervals, main_audio, sub_audio),
stream_preference,
)
durations: list[int] = []
clips: list[dict[str, Any]] = []
# gathered after glitch-nulling and span building, so the policy
# decisions below reflect the manifest's real contents
video_codecs: set[str] = set()
audio_presence: set[bool] = set()
audio_params: set[tuple[str | None, int | None]] = set()
span_streams: set[bool] = set()
for row, span_start, span_end, span_is_main in spans:
logger.debug(
"VOD: processing recording: %s start=%s end=%s duration=%s",
row.path,
row.start_time,
row.end_time,
row.duration,
)
built = _build_vod_clip(row, span_start, span_end)
if built is None:
continue
clips.append(built[0])
durations.append(built[1])
span_streams.add(span_is_main)
if row.video_codec is not None:
video_codecs.add(row.video_codec)
audio_presence.add(row.has_audio is not False)
# legacy rows contribute no signature, so uniformly-unknown
# history keeps the legacy shape
if row.has_audio is not False and (
row.audio_codec is not None or row.audio_rate is not None
):
audio_params.add((row.audio_codec, row.audio_rate))
# nginx-vod requires a uniform track count per sequence, and adding or
# removing an audio track across an MSE discontinuity is unproven
if len(audio_presence) > 1:
logger.debug(
"VOD: %s mixes audio-bearing and audio-less recordings between "
"%s and %s; serving the range without audio",
camera_name,
start_ts,
end_ts,
)
for clip in clips:
clip["tracks"] = "v"
# discontinuity mode emits per-clip init segments, letting the decoder
# reconfigure at each boundary. Stream type counts as a signature of
# its own: the two encoders differ in SPS/PPS even when codec name and
# audio params match, and a single-init manifest then decode-fails on
# players that only configure from the init segment (iOS)
use_discontinuity = (
len(video_codecs) > 1 or len(audio_params) > 1 or len(span_streams) > 1
)
if use_discontinuity:
logger.debug(
"VOD: %s mixes media signatures between %s and %s (video codecs "
"%s, audio params %s, streams %s); serving a discontinuity "
"manifest with per-clip init segments",
camera_name,
start_ts,
end_ts,
sorted(video_codecs),
sorted(audio_params, key=str),
sorted(span_streams),
)
if not clips:
logger.error(
f"No recordings found for {camera_name} during the requested time range"
)
return JSONResponse(
content={
"success": False,
"message": "No recordings found.",
},
status_code=404,
)
if len(clips) > NGINX_VOD_MAX_CLIPS:
logger.warning(
"VOD: %s needs %d clips between %s and %s, exceeding nginx's "
"limit of %d; playback of this range will fail. This usually "
"means the camera produced abnormally short recording segments "
"(check the stream's timestamps)",
camera_name,
len(clips),
start_ts,
end_ts,
NGINX_VOD_MAX_CLIPS,
)
# segmentation comes from the vod_* nginx directives plus per-clip
# keyFrameDurations; a segment_duration field here was always ignored
# (nginx-vod parses only camelCase segmentDuration)
hour_ago = datetime.now() - timedelta(hours=1)
content = {
"cache": hour_ago.timestamp() > start_ts,
"discontinuity": force_discontinuity or use_discontinuity,
"consistentSequenceMediaInfo": True,
"durations": durations,
"sequences": [{"clips": clips}],
}
if use_discontinuity:
# clip-indexed naming is what makes nginx-vod emit per-clip
# EXT-X-MAP outside of its live mode
content["initialClipIndex"] = 1
return JSONResponse(content=content)
@router.get(
"/vod/{camera_name}/start/{start_ts}/end/{end_ts}",
dependencies=[Depends(require_camera_access)],
@@ -815,8 +560,134 @@ async def vod_ts(
end_ts: float,
force_discontinuity: bool = False,
):
return await _vod_response(
camera_name, start_ts, end_ts, force_discontinuity=force_discontinuity
logger.debug(
"VOD: Generating VOD for %s from %s to %s with force_discontinuity=%s",
camera_name,
start_ts,
end_ts,
force_discontinuity,
)
recordings = (
Recordings.select(
Recordings.path,
Recordings.duration,
Recordings.end_time,
Recordings.start_time,
)
.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)
.order_by(Recordings.start_time.asc())
.iterator()
)
clips = []
durations = []
min_duration_ms = 100 # Minimum 100ms to ensure at least one video frame
max_duration_ms = MAX_SEGMENT_DURATION * 1000
recording: Recordings
for recording in recordings:
logger.debug(
"VOD: processing recording: %s start=%s end=%s duration=%s",
recording.path,
recording.start_time,
recording.end_time,
recording.duration,
)
clip = {"type": "source", "path": recording.path}
duration = int(recording.duration * 1000)
# adjust start offset if start_ts is after recording.start_time
if start_ts > recording.start_time:
inpoint = int((start_ts - recording.start_time) * 1000)
clip["clipFrom"] = inpoint
duration -= inpoint
logger.debug(
"VOD: applied clipFrom %sms to %s",
inpoint,
recording.path,
)
# adjust end if recording.end_time is after end_ts
if recording.end_time > end_ts:
duration -= int((recording.end_time - end_ts) * 1000)
# nginx-vod-module pushes clipFrom forward to the next keyframe,
# which can leave too few frames and produce an empty/unplayable
# segment. Snap clipFrom back to the preceding keyframe so the
# segment always starts with a decodable frame.
if "clipFrom" in clip:
keyframe_ms = get_keyframe_before(recording.path, clip["clipFrom"])
if keyframe_ms is not None:
gained = clip["clipFrom"] - keyframe_ms
clip["clipFrom"] = keyframe_ms
duration += gained
logger.debug(
"VOD: snapped clipFrom to keyframe at %sms for %s, duration now %sms",
keyframe_ms,
recording.path,
duration,
)
else:
# could not read keyframes, remove clipFrom to use full recording
logger.debug(
"VOD: no keyframe info for %s, removing clipFrom to use full recording",
recording.path,
)
del clip["clipFrom"]
duration = int(recording.duration * 1000)
if recording.end_time > end_ts:
duration -= int((recording.end_time - end_ts) * 1000)
if duration < min_duration_ms:
# skip if the clip has no valid duration (too short to contain frames)
logger.debug(
"VOD: skipping recording %s - resulting duration %sms too short",
recording.path,
duration,
)
continue
if min_duration_ms <= duration < max_duration_ms:
clip["keyFrameDurations"] = [duration]
clips.append(clip)
durations.append(duration)
logger.debug(
"VOD: added clip %s duration_ms=%s clipFrom=%s",
recording.path,
duration,
clip.get("clipFrom"),
)
else:
logger.warning(f"Recording clip is missing or empty: {recording.path}")
if not clips:
logger.error(
f"No recordings found for {camera_name} during the requested time range"
)
return JSONResponse(
content={
"success": False,
"message": "No recordings found.",
},
status_code=404,
)
hour_ago = datetime.now() - timedelta(hours=1)
return JSONResponse(
content={
"cache": hour_ago.timestamp() > start_ts,
"discontinuity": force_discontinuity,
"consistentSequenceMediaInfo": True,
"durations": durations,
"segment_duration": max(durations),
"sequences": [{"clips": clips}],
}
)
@@ -905,43 +776,7 @@ async def vod_clip(
start_ts: float,
end_ts: float,
):
# the tracking-details player corrects its timeline from
# sequences[0].clips[0].clipFrom
return await _vod_response(
camera_name,
start_ts,
end_ts,
force_discontinuity=True,
)
# registered after /vod/clip/... on purpose: both routes are six path
# segments, Starlette matches structurally in registration order, and the
# enum validation on {stream} would otherwise 422 every /vod/clip request
@router.get(
"/vod/{camera_name}/{stream}/start/{start_ts}/end/{end_ts}",
dependencies=[Depends(require_camera_access)],
description="Returns an HLS playlist pinned to one stream type (main or sub) for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
)
async def vod_ts_stream(
camera_name: str,
stream: VodStreamPreference,
start_ts: float,
end_ts: float,
force_discontinuity: bool = False,
):
"""VOD for a timestamp range pinned to one stream type.
How the frontend selects quality, now that mappings are always
single-sequence.
"""
return await _vod_response(
camera_name,
start_ts,
end_ts,
force_discontinuity=force_discontinuity,
stream_preference=stream.value,
)
return await vod_ts(camera_name, start_ts, end_ts, force_discontinuity=True)
@router.get(
@@ -979,13 +814,13 @@ async def event_snapshot(
timestamp_style=request.app.frigate_config.cameras[
event.camera
].timestamp_style,
colormap=request.app.frigate_config.model_for_camera(event.camera).colormap,
colormap=request.app.frigate_config.model.colormap,
)
except DoesNotExist:
# see if the object is currently being tracked
try:
camera_states: list[CameraState] = (
request.app.detected_frames_processor.get_camera_states()
request.app.detected_frames_processor.camera_states.values()
)
for camera_state in camera_states:
if event_id in camera_state.tracked_objects:
@@ -1063,7 +898,7 @@ async def event_thumbnail(
if thumbnail_bytes is None:
# see if the object is currently being tracked
try:
camera_states = request.app.detected_frames_processor.get_camera_states()
camera_states = request.app.detected_frames_processor.camera_states.values()
for camera_state in camera_states:
if event_id in camera_state.tracked_objects:
tracked_obj = camera_state.tracked_objects.get(event_id)
@@ -1292,7 +1127,7 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool =
# see if the object is currently being tracked
try:
camera_states = (
request.app.detected_frames_processor.get_camera_states()
request.app.detected_frames_processor.camera_states.values()
)
for camera_state in camera_states:
if event_id in camera_state.tracked_objects:
-102
View File
@@ -1,10 +1,8 @@
"""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
@@ -21,95 +19,6 @@ 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"))
if reason:
return reason
keys = sub.get("keys")
if not isinstance(keys, dict):
return "subscription must include keys"
# WebPusher raises on a missing key, which would break every send for the
# user rather than just this registration
for name in ("p256dh", "auth"):
value = keys.get(name)
if not isinstance(value, str) or not value:
return f"subscription keys must include {name}"
return None
@router.get(
"/notifications/pubkey",
@@ -162,17 +71,6 @@ def register_notifications(request: Request, body: dict = None):
status_code=400,
)
reason = _validate_subscription(sub)
if reason:
logger.warning(
"Rejected notification registration for %s: %s", username, reason
)
return JSONResponse(
content={"success": False, "message": f"Invalid subscription: {reason}"},
status_code=400,
)
try:
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
User.username == username
+56 -190
View File
@@ -25,20 +25,8 @@ from frigate.api.defs.query.recordings_query_parameters import (
)
from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags
from frigate.const import (
MAX_SEGMENT_DURATION,
RECORD_DIR,
STREAM_TYPE_MAIN,
STREAM_TYPE_SUB,
)
from frigate.const import RECORD_DIR
from frigate.models import Event, Recordings
from frigate.util.recording_coverage import (
coverage_spans,
known_video_codecs,
realized_timelines,
resolve_coverage,
stream_media_summary,
)
from frigate.util.time import get_dst_transitions
logger = logging.getLogger(__name__)
@@ -71,7 +59,7 @@ def get_recordings_storage_usage(request: Request):
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
async def all_recordings_summary(
def all_recordings_summary(
request: Request,
params: MediaRecordingsSummaryQueryParams = Depends(),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
@@ -88,23 +76,18 @@ async def all_recordings_summary(
else:
camera_list = allowed_cameras
min_time: float | None = None
max_time: float | None = None
for camera in camera_list:
cam_min = (
Recordings.select(fn.MIN(Recordings.start_time))
.where(Recordings.camera == camera)
.scalar()
time_range_query = (
Recordings.select(
fn.MIN(Recordings.start_time).alias("min_time"),
fn.MAX(Recordings.start_time).alias("max_time"),
)
if cam_min is None:
continue
cam_max = (
Recordings.select(fn.MAX(Recordings.start_time))
.where(Recordings.camera == camera)
.scalar()
)
min_time = cam_min if min_time is None else min(min_time, cam_min)
max_time = cam_max if max_time is None else max(max_time, cam_max)
.where(Recordings.camera << camera_list)
.dicts()
.get()
)
min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
if min_time is None or max_time is None:
return JSONResponse(content={})
@@ -114,60 +97,22 @@ async def all_recordings_summary(
days: dict[str, bool] = {}
for period_start, period_end, period_offset in dst_periods:
first_start = max(min_time, period_start - MAX_SEGMENT_DURATION)
first_day = int((first_start + period_offset) // 86400)
last_day = int((min(max_time, period_end) + period_offset) // 86400)
day_expr = ((Recordings.start_time + period_offset) / 86400).cast("int")
day_idx = first_day
while day_idx <= last_day:
day_str = (dt.date(1970, 1, 1) + dt.timedelta(days=day_idx)).isoformat()
day_start = day_idx * 86400 - period_offset
day_end = day_start + 86400
if day_str in days:
day_idx += 1
continue
if day_end <= period_end:
upper = Recordings.start_time < day_end
else:
upper = Recordings.start_time <= period_end
has_recordings = (
Recordings.select(Recordings.id)
.where(
(Recordings.camera << camera_list)
& (Recordings.end_time >= period_start)
& (Recordings.start_time >= day_start)
& upper
)
.exists()
period_query = (
Recordings.select(day_expr.alias("day_idx"))
.where(
(Recordings.camera << camera_list)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
if has_recordings:
days[day_str] = True
day_idx += 1
continue
.distinct()
.namedtuples()
)
# empty day
next_start: float | None = None
for camera in camera_list:
cam_next = (
Recordings.select(fn.MIN(Recordings.start_time))
.where(
Recordings.camera == camera,
Recordings.start_time >= day_end,
Recordings.start_time <= period_end,
)
.scalar()
)
if cam_next is not None and (
next_start is None or cam_next < next_start
):
next_start = cam_next
if next_start is None:
break
day_idx = max(day_idx + 1, int((next_start + period_offset) // 86400))
for g in period_query:
day_str = (dt.date(1970, 1, 1) + dt.timedelta(days=g.day_idx)).isoformat()
days[day_str] = True
return JSONResponse(content=dict(sorted(days.items())))
@@ -204,28 +149,23 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"):
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
hour_expression = fn.strftime(
"%Y-%m-%d %H",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
)
# sub rows duplicate the camera's motion/object stats, so
# aggregating them too would double-count
recording_groups = (
Recordings.select(
hour_expression.alias("hour"),
fn.strftime(
"%Y-%m-%d %H",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("hour"),
fn.SUM(Recordings.duration).alias("duration"),
fn.SUM(Recordings.motion).alias("motion"),
fn.SUM(Recordings.objects).alias("objects"),
)
.where(
(Recordings.camera == camera_name)
& (Recordings.stream_type == STREAM_TYPE_MAIN)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
@@ -234,23 +174,6 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"):
.namedtuples()
)
# sub recordings can outlive main, so hours covered only by sub
# rows are reported too, flagged as sub_only
sub_groups = (
Recordings.select(
hour_expression.alias("hour"),
fn.SUM(Recordings.duration).alias("duration"),
)
.where(
(Recordings.camera == camera_name)
& (Recordings.stream_type == STREAM_TYPE_SUB)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.group_by((Recordings.start_time + period_offset).cast("int") / 3600)
.namedtuples()
)
event_groups = (
Event.select(
fn.strftime(
@@ -274,43 +197,17 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"):
event_map = {g.hour: g.count for g in event_groups}
hour_stats = [
(
g.hour,
{
"motion": g.motion,
"objects": g.objects,
"duration": round(g.duration),
},
)
for g in recording_groups
]
main_hours = {group_hour for group_hour, _ in hour_stats}
hour_stats.extend(
(
g.hour,
{
"motion": 0,
"objects": 0,
"duration": round(g.duration),
"sub_only": True,
},
)
for g in sub_groups
if g.hour not in main_hours
)
# restore the most-recent-first ordering after merging in sub hours
hour_stats.sort(key=lambda entry: entry[0], reverse=True)
for group_hour, stats in hour_stats:
parts = group_hour.split()
for recording_group in recording_groups:
parts = recording_group.hour.split()
hour = parts[1]
day = parts[0]
events_count = event_map.get(group_hour, 0)
events_count = event_map.get(recording_group.hour, 0)
hour_data = {
"hour": hour,
"events": events_count,
**stats,
"motion": recording_group.motion,
"objects": recording_group.objects,
"duration": round(recording_group.duration),
}
if day in days:
# merge counts if already present (edge-case at DST boundary)
@@ -326,35 +223,6 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"):
return JSONResponse(content=list(days.values()))
@router.get(
"/{camera_name}/recordings/coverage",
dependencies=[Depends(require_camera_access)],
)
async def recordings_coverage(
camera_name: str, after: float, before: float, timelines: bool = False
):
"""Returns merged recording coverage spans plus codec compatibility.
codecs_compatible is false only when more than one known video codec
appears across the range's rows, the case where the merged vod route
degrades to a single-stream manifest.
"""
intervals = resolve_coverage(camera_name, after, before)
content = {
"spans": coverage_spans(intervals),
"codecs_compatible": len(known_video_codecs(intervals)) <= 1,
"streams": stream_media_summary(intervals),
}
# pure computation (shared plan_clip, record-time keyframe index), but
# opt-in for payload hygiene: day-level requests need only the spans
if timelines:
content["timelines"] = realized_timelines(intervals)
return JSONResponse(content=content)
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
async def recordings(
camera_name: str,
@@ -375,8 +243,6 @@ async def recordings(
)
.where(
Recordings.camera == camera_name,
Recordings.stream_type == STREAM_TYPE_MAIN,
Recordings.start_time >= after - MAX_SEGMENT_DURATION,
Recordings.end_time >= after,
Recordings.start_time <= before,
)
@@ -416,22 +282,22 @@ async def no_recordings(
)
scale = params.scale
recordings: list[tuple[float, float]] = []
for camera in camera_list:
recordings.extend(
Recordings.select(Recordings.start_time, Recordings.end_time)
.where(
Recordings.camera == camera,
Recordings.start_time >= after - MAX_SEGMENT_DURATION,
Recordings.end_time >= after,
Recordings.start_time <= before,
)
.tuples()
.iterator()
)
clauses = [
(Recordings.end_time >= after) & (Recordings.start_time <= before),
(Recordings.camera << camera_list),
]
# the merge pass below expects a single start-ordered timeline
recordings.sort()
# Get recording start times
data: list[Recordings] = (
Recordings.select(Recordings.start_time, Recordings.end_time)
.where(reduce(operator.and_, clauses))
.order_by(Recordings.start_time.asc())
.dicts()
.iterator()
)
# Convert recordings to list of (start, end) tuples, ordered by start_time
recordings = [(r["start_time"], r["end_time"]) for r in data]
# Merge overlapping/adjacent recordings into covered intervals. The query
# orders by start_time, so a single pass merges them
+1 -8
View File
@@ -17,7 +17,6 @@ from frigate.api.auth import (
get_allowed_cameras_for_filter,
get_current_user,
require_camera_access,
require_full_camera_access,
require_role,
)
from frigate.api.defs.query.review_query_parameters import (
@@ -33,7 +32,6 @@ from frigate.api.defs.response.review_response import (
ReviewSummaryResponse,
)
from frigate.api.defs.tags import Tags
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
@@ -599,8 +597,6 @@ def motion_activity(
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
clauses.append(Recordings.motion > 0)
# sub rows duplicate the camera's motion stats, so only count main rows
clauses.append(Recordings.stream_type == STREAM_TYPE_MAIN)
if cameras != "all":
requested = set(cameras.split(","))
@@ -750,12 +746,9 @@ async def set_not_reviewed(
)
# Intentionally not camera scoped, as the summary correlates each flagged event
# with overlapping activity on other cameras. Restricted to callers who can
# already see every camera, so the unscoped query discloses nothing.
@router.post(
"/review/summarize/start/{start_ts}/end/{end_ts}",
dependencies=[Depends(require_full_camera_access)],
dependencies=[Depends(require_role(["admin"]))],
description="Use GenAI to summarize review items over a period of time.",
)
def generate_review_summary(request: Request, start_ts: float, end_ts: float):
+22 -42
View File
@@ -49,8 +49,6 @@ from frigate.debug_replay import (
DebugReplayManager,
cleanup_replay_cameras,
)
from frigate.detectors.detector_config import SceneEnum
from frigate.detectors.device import build_detector_config, runner_names
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
from frigate.events.audio import AudioProcessor
from frigate.events.cleanup import EventCleanup
@@ -71,7 +69,6 @@ from frigate.models import (
User,
)
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 PtzAutoTrackerThread
from frigate.ptz.onvif import OnvifController
@@ -86,7 +83,6 @@ from frigate.timeline import TimelineProcessor
from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import UntrackedSharedMemory
from frigate.util.ownership import chown_to_runtime
from frigate.util.process import FrigateProcess
from frigate.util.services import set_file_limit
from frigate.version import VERSION
@@ -102,9 +98,7 @@ class FrigateApp:
self.metrics_manager = manager
self.audio_process: mp.Process | None = None
self.stop_event = stop_event
self.detection_queues: dict[SceneEnum, Queue] = {
model.scene: mp.Queue() for model in config.models
}
self.detection_queue: Queue = mp.Queue()
self.detectors: dict[str, ObjectDetectProcess] = {}
self.detection_shms: list[mp.shared_memory.SharedMemory] = []
self.log_queue: Queue = mp.Queue()
@@ -150,7 +144,6 @@ class FrigateApp:
if not os.path.exists(d) and not os.path.islink(d):
logger.info(f"Creating directory: {d}")
os.makedirs(d, exist_ok=True)
chown_to_runtime(d)
else:
logger.debug(f"Skipping directory: {d}")
@@ -342,7 +335,6 @@ class FrigateApp:
self.ptz_metrics,
comms,
)
self.dispatcher.start_communicators()
def init_profile_manager(self) -> None:
self.profile_manager = ProfileManager(
@@ -351,19 +343,20 @@ class FrigateApp:
self.dispatcher.profile_manager = self.profile_manager
def start_detectors(self) -> None:
model_cameras: dict[SceneEnum, list[str]] = {
model.scene: [] for model in self.config.models
}
for name in self.config.cameras.keys():
model = self.config.model_for_camera(name)
model_cameras[model.scene].append(name)
try:
largest_frame = max(
[
det.model.height * det.model.width * 3
if det.model is not None
else 320
for det in self.config.detectors.values()
]
)
shm_in = UntrackedSharedMemory(
name=name,
create=True,
size=detection_frame_size(model),
size=largest_frame,
)
except FileExistsError:
shm_in = UntrackedSharedMemory(name=name)
@@ -378,26 +371,15 @@ class FrigateApp:
self.detection_shms.append(shm_in)
self.detection_shms.append(shm_out)
# a device may be listed more than once to run additional inference
# processes on it, so names are only unique once de-duplicated
all_devices = [
device
for model in self.config.models
for device in self.config.devices_for_model(model)
]
names = iter(runner_names(all_devices))
for model in self.config.models:
for device in self.config.devices_for_model(model):
name = next(names)
self.detectors[name] = ObjectDetectProcess(
name,
self.detection_queues[model.scene],
model_cameras[model.scene],
self.config,
build_detector_config(device, model),
self.stop_event,
)
for name, detector_config in self.config.detectors.items():
self.detectors[name] = ObjectDetectProcess(
name,
self.detection_queue,
list(self.config.cameras.keys()),
self.config,
detector_config,
self.stop_event,
)
def start_ptz_autotracker(self) -> None:
self.ptz_autotracker_thread = PtzAutoTrackerThread(
@@ -428,7 +410,7 @@ class FrigateApp:
def start_camera_processor(self) -> None:
self.camera_maintainer = CameraMaintainer(
self.config,
self.detection_queues,
self.detection_queue,
self.detected_frames_queue,
self.camera_metrics,
self.ptz_metrics,
@@ -692,10 +674,8 @@ class FrigateApp:
for detector in self.detectors.values():
detector.stop()
for detection_queue in self.detection_queues.values():
empty_and_close_queue(detection_queue)
logger.info("Detection queues closed")
empty_and_close_queue(self.detection_queue)
logger.info("Detection queue closed")
self.detected_frames_processor.join()
empty_and_close_queue(self.detected_frames_queue)
+1 -2
View File
@@ -18,7 +18,6 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
from frigate.detectors.detector_config import NON_LOGO_ATTRIBUTES
logger = logging.getLogger(__name__)
@@ -179,7 +178,7 @@ class CameraActivityManager:
return
for label in camera_config.objects.track:
if label in NON_LOGO_ATTRIBUTES:
if label in self.config.model.non_logo_attributes:
continue
new_count = all_objects[label]
+16 -12
View File
@@ -15,9 +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
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
from frigate.util.object import get_camera_regions_grid
@@ -31,7 +29,7 @@ class CameraMaintainer(threading.Thread):
def __init__(
self,
config: FrigateConfig,
detection_queues: dict[SceneEnum, Queue],
detection_queue: Queue,
detected_frames_queue: Queue,
camera_metrics: DictProxy,
ptz_metrics: dict[str, PTZMetrics],
@@ -40,7 +38,7 @@ class CameraMaintainer(threading.Thread):
):
super().__init__(name="camera_processor")
self.config = config
self.detection_queues = detection_queues
self.detection_queue = detection_queue
self.detected_frames_queue = detected_frames_queue
self.stop_event = stop_event
self.camera_metrics = camera_metrics
@@ -81,11 +79,10 @@ class CameraMaintainer(threading.Thread):
# create or update region grids for each camera
for camera in self.config.cameras.values():
assert camera.name is not None
model = self.config.model_for_camera(camera.name)
self.region_grids[camera.name] = get_camera_regions_grid(
camera.name,
camera.detect,
max(model.width, model.height),
max(self.config.model.width, self.config.model.height),
)
def __calculate_shm_frame_count(self) -> int:
@@ -117,7 +114,6 @@ class CameraMaintainer(threading.Thread):
return
camera_stop_event = self.__ensure_camera_stop_event(name)
model = self.config.model_for_camera(name)
if runtime:
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
@@ -127,24 +123,32 @@ class CameraMaintainer(threading.Thread):
self.region_grids[name] = get_camera_regions_grid(
name,
config.detect,
max(model.width, model.height),
max(self.config.model.width, self.config.model.height),
)
try:
largest_frame = max(
[
det.model.height * det.model.width * 3
if det.model is not None
else 320
for det in self.config.detectors.values()
]
)
UntrackedSharedMemory(name=f"out-{name}", create=True, size=20 * 6 * 4)
UntrackedSharedMemory(
name=name,
create=True,
size=detection_frame_size(model),
size=largest_frame,
)
except FileExistsError:
pass
camera_process = CameraTracker(
config,
model,
model.merged_labelmap,
self.detection_queues[model.scene],
self.config.model,
self.config.model.merged_labelmap,
self.detection_queue,
self.detected_frames_queue,
self.camera_metrics[name],
self.ptz_metrics[name],
+11 -6
View File
@@ -40,7 +40,6 @@ class CameraState:
self.name = name
self.config = config
self.camera_config = config.cameras[name]
self.model = config.model_for_camera(name)
self.frame_manager = frame_manager
self.best_objects: dict[str, TrackedObject] = {}
self.tracked_objects: dict[str, TrackedObject] = {}
@@ -102,7 +101,9 @@ class CameraState:
thickness = 1
else:
thickness = 2
color = self.model.colormap.get(obj["label"], (255, 255, 255))
color = self.config.model.colormap.get(
obj["label"], (255, 255, 255)
)
else:
thickness = 1
color = (255, 0, 0)
@@ -124,7 +125,9 @@ class CameraState:
and obj["frame_time"] == frame_time
):
thickness = 5
color = self.model.colormap.get(obj["label"], (255, 255, 255))
color = self.config.model.colormap.get(
obj["label"], (255, 255, 255)
)
# debug autotracking zooming - show the zoom factor box
if (
@@ -258,7 +261,9 @@ class CameraState:
if draw_options.get("paths"):
for obj in tracked_objects.values():
if obj["frame_time"] == frame_time and obj["path_data"]:
color = self.model.colormap.get(obj["label"], (255, 255, 255))
color = self.config.model.colormap.get(
obj["label"], (255, 255, 255)
)
path_points = [
(
@@ -361,7 +366,7 @@ class CameraState:
for id in new_ids:
logger.debug(f"{self.name}: New tracked object ID: {id}")
new_obj = tracked_objects[id] = TrackedObject(
self.model,
self.config.model,
self.camera_config,
self.config.ui,
self.frame_cache,
@@ -505,7 +510,7 @@ class CameraState:
sub_label = None
if obj.obj_data.get("sub_label"):
if obj.obj_data["sub_label"][0] in self.model.all_attributes:
if obj.obj_data["sub_label"][0] in self.config.model.all_attributes:
label = obj.obj_data["sub_label"][0]
else:
label = f"{object_type}-verified"
+1 -17
View File
@@ -1,27 +1,11 @@
from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from frigate.comms.dispatcher import Dispatcher
from typing import Any
class Communicator(ABC):
"""pub/sub model via specific protocol."""
def attach_dispatcher(self, dispatcher: "Dispatcher") -> None:
"""Receive the owning dispatcher.
Transports that need more than the receiver callback (the command topic
surface, the snapshot API) take it here rather than reaching through the
bound receiver.
"""
return None
def start(self) -> None:
"""Start background I/O after receiver wiring is complete."""
return None
@abstractmethod
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Send data via specific protocol."""
+77 -148
View File
@@ -6,18 +6,12 @@ import logging
from collections.abc import Callable, Iterable
from typing import Any, cast
from peewee import IntegrityError
from frigate.camera import PTZMetrics
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
from frigate.comms.base_communicator import Communicator
from frigate.comms.runtime_state import RuntimeStatePersistence
from frigate.comms.webpush import WebPushClient
from frigate.config import (
FrigateConfig,
birdseye_modes_from_mqtt_payload,
birdseye_modes_to_mqtt_payload,
)
from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdatePublisher,
@@ -51,11 +45,6 @@ from frigate.util.services import restart_frigate
logger = logging.getLogger(__name__)
# <camera>/<command>/<sub_command>/set, one segment longer than the rest
SUB_COMMAND_TOPICS = frozenset({"motion_mask", "object_mask", "zone"})
BARE_COMMAND_TOPICS = frozenset({"onConnect", "restart"})
class Dispatcher:
"""Handle communication between Frigate and communicators."""
@@ -95,7 +84,7 @@ class Dispatcher:
"recordings": self._on_recordings_command,
"snapshots": self._on_snapshots_command,
"birdseye": self._on_birdseye_command,
"birdseye_modes": self._on_birdseye_modes_command,
"birdseye_mode": self._on_birdseye_mode_command,
"review_alerts": self._on_alerts_command,
"review_detections": self._on_detections_command,
"object_descriptions": self._on_object_description_command,
@@ -110,113 +99,11 @@ class Dispatcher:
}
self.profile_manager: ProfileManager | None = None
self.web_push_client = next(
(comm for comm in communicators if isinstance(comm, WebPushClient)), None
)
for comm in self.comms:
comm.subscribe(self._receive)
comm.attach_dispatcher(self)
def start_communicators(self) -> None:
"""Start communicators after dispatcher wiring is fully initialized."""
for comm in self.comms:
comm.start()
def is_command_topic(self, topic: str) -> bool:
"""Whether a prefix-stripped topic maps to a command handler.
Transports that fan a whole topic tree in must filter on this:
_receive() republishes anything it does not recognize, so forwarding
unfiltered would echo Frigate's own publishes back.
"""
parts = topic.split("/")
if topic in BARE_COMMAND_TOPICS:
return True
if len(parts) == 2 and parts[1] == "ptz":
return True
if len(parts) == 2 and parts[1] == "set":
return parts[0] in self._global_settings_handlers
if len(parts) == 3 and parts[2] == "set":
return (
parts[1] in self._camera_settings_handlers
and parts[1] not in SUB_COMMAND_TOPICS
)
if len(parts) == 3 and parts[2] == "suspend":
return parts[1] == "notifications"
if len(parts) == 4 and parts[3] == "set":
return parts[1] in SUB_COMMAND_TOPICS
return False
def _build_camera_activity_snapshot(self) -> tuple[dict[str, Any], dict[str, Any]]:
"""Build the current runtime activity snapshot for reconnect consumers."""
camera_status = {
camera: status
for camera, status in self.camera_activity.last_camera_activity.copy().items()
if camera in self.config.cameras
}
audio_detections = self.audio_activity.current_audio_detections.copy()
cameras_with_status = camera_status.keys()
for camera in self.config.cameras.keys():
if camera not in cameras_with_status:
camera_status[camera] = {}
camera_status[camera]["config"] = {
"detect": self.config.cameras[camera].detect.enabled,
"enabled": self.config.cameras[camera].enabled,
"snapshots": self.config.cameras[camera].snapshots.enabled,
"record": self.config.cameras[camera].record.enabled,
"audio": self.config.cameras[camera].audio.enabled,
"audio_transcription": self.config.cameras[
camera
].audio_transcription.live_enabled,
"notifications": self.config.cameras[camera].notifications.enabled,
"notifications_suspended": int(
self.web_push_client.suspended_cameras.get(camera, 0)
)
if self.web_push_client
and camera in self.web_push_client.suspended_cameras
else 0,
"autotracking": self.config.cameras[camera].onvif.autotracking.enabled,
"alerts": self.config.cameras[camera].review.alerts.enabled,
"detections": self.config.cameras[camera].review.detections.enabled,
"object_descriptions": self.config.cameras[
camera
].objects.genai.enabled,
"review_descriptions": self.config.cameras[camera].review.genai.enabled,
}
return camera_status, audio_detections
def publish_runtime_snapshot(
self,
publisher: Callable[[str, Any, bool], None] | None = None,
) -> None:
"""Publish the runtime snapshot for newly connected listeners."""
publish = publisher or self.publish
camera_status, audio_detections = self._build_camera_activity_snapshot()
publish("camera_activity", json.dumps(camera_status), False)
publish("model_state", json.dumps(self.model_state.copy()), False)
publish(
"embeddings_reindex_progress",
json.dumps(self.embeddings_reindex.copy()),
False,
)
publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()), False)
publish("audio_detections", json.dumps(audio_detections), False)
publish(
"profile/state",
self.config.active_profile or "none",
True,
self.web_push_client = next(
(comm for comm in communicators if isinstance(comm, WebPushClient)), None
)
if self.web_push_client is not None:
self.web_push_client.set_suspension_broadcaster(self.publish)
@@ -236,11 +123,17 @@ class Dispatcher:
try:
if command_type == "set":
# Commands that require a sub-command (mask/zone name)
sub_command_required = {
"motion_mask",
"object_mask",
"zone",
}
if sub_command:
self._camera_settings_handlers[command](
camera_name, sub_command, payload
)
elif command in SUB_COMMAND_TOPICS:
elif command in sub_command_required:
logger.error(
"Command %s requires a sub-command (mask/zone name)",
command,
@@ -256,32 +149,17 @@ class Dispatcher:
restart_frigate()
def handle_insert_many_recordings() -> None:
try:
Recordings.insert_many(payload).execute()
except IntegrityError:
logger.warning(
"Batch recording insert failed, inserting rows individually"
)
for recording in payload:
try:
Recordings.insert(recording).execute()
except IntegrityError:
logger.warning(
"Skipping recording that is already stored: %s",
recording.get(Recordings.path.name),
)
Recordings.insert_many(payload).execute()
def handle_request_region_grid() -> Any:
camera = payload
if camera not in self.config.cameras:
return None
model = self.config.model_for_camera(camera)
grid = get_camera_regions_grid(
camera,
self.config.cameras[camera].detect,
max(model.width, model.height),
max(self.config.model.width, self.config.model.height),
)
return grid
@@ -389,11 +267,67 @@ class Dispatcher:
def handle_birdseye_layout() -> None:
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
def handle_on_connect() -> None:
camera_status = {
camera: status
for camera, status in self.camera_activity.last_camera_activity.copy().items()
if camera in self.config.cameras
}
audio_detections = self.audio_activity.current_audio_detections.copy()
cameras_with_status = camera_status.keys()
for camera in self.config.cameras.keys():
if camera not in cameras_with_status:
camera_status[camera] = {}
camera_status[camera]["config"] = {
"detect": self.config.cameras[camera].detect.enabled,
"enabled": self.config.cameras[camera].enabled,
"snapshots": self.config.cameras[camera].snapshots.enabled,
"record": self.config.cameras[camera].record.enabled,
"audio": self.config.cameras[camera].audio.enabled,
"audio_transcription": self.config.cameras[
camera
].audio_transcription.live_enabled,
"notifications": self.config.cameras[camera].notifications.enabled,
"notifications_suspended": int(
self.web_push_client.suspended_cameras.get(camera, 0)
)
if self.web_push_client
and camera in self.web_push_client.suspended_cameras
else 0,
"autotracking": self.config.cameras[
camera
].onvif.autotracking.enabled,
"alerts": self.config.cameras[camera].review.alerts.enabled,
"detections": self.config.cameras[camera].review.detections.enabled,
"object_descriptions": self.config.cameras[
camera
].objects.genai.enabled,
"review_descriptions": self.config.cameras[
camera
].review.genai.enabled,
}
self.publish("camera_activity", json.dumps(camera_status))
self.publish("model_state", json.dumps(self.model_state.copy()))
self.publish(
"embeddings_reindex_progress",
json.dumps(self.embeddings_reindex.copy()),
)
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
self.publish("audio_detections", json.dumps(audio_detections))
self.publish(
"profile/state",
self.config.active_profile or "none",
retain=True,
)
def handle_notification_test() -> None:
self.publish("notification_test", "Test notification")
# Dictionary mapping topic to handlers
topic_handlers: dict[str, Callable[[], Any]] = {
topic_handlers = {
INSERT_MANY_RECORDINGS: handle_insert_many_recordings,
REQUEST_REGION_GRID: handle_request_region_grid,
INSERT_PREVIEW: handle_insert_preview,
@@ -416,7 +350,7 @@ class Dispatcher:
"jobState": handle_job_state,
"audioTranscriptionState": handle_audio_transcription_state,
"birdseyeLayout": handle_birdseye_layout,
"onConnect": self.publish_runtime_snapshot,
"onConnect": handle_on_connect,
}
if topic.endswith("set") or topic.endswith("ptz") or topic.endswith("suspend"):
@@ -945,12 +879,11 @@ class Dispatcher:
)
self.publish(f"{camera_name}/birdseye/state", payload, retain=True)
def _on_birdseye_modes_command(self, camera_name: str, payload: str) -> None:
def _on_birdseye_mode_command(self, camera_name: str, payload: str) -> None:
"""Callback for birdseye mode topic."""
modes = birdseye_modes_from_mqtt_payload(payload)
if modes is None:
logger.info("Invalid birdseye_modes command: %s", payload)
if payload not in ["CONTINUOUS", "MOTION", "OBJECTS"]:
logger.info(f"Invalid birdseye_mode command: {payload}")
return
birdseye_settings = self.config.cameras[camera_name].birdseye
@@ -959,20 +892,16 @@ class Dispatcher:
logger.info(f"Birdseye mode not enabled for {camera_name}")
return
birdseye_settings.modes = modes
birdseye_settings.mode = BirdseyeModeEnum(payload.lower())
logger.info(
f"Setting birdseye mode for {camera_name} to {birdseye_settings.modes}"
f"Setting birdseye mode for {camera_name} to {birdseye_settings.mode}"
)
self.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.birdseye, camera_name),
birdseye_settings,
)
self.publish(
f"{camera_name}/birdseye_modes/state",
birdseye_modes_to_mqtt_payload(modes),
retain=True,
)
self.publish(f"{camera_name}/birdseye_mode/state", payload, retain=True)
def _on_camera_notification_command(self, camera_name: str, payload: str) -> None:
"""Callback for camera level notifications topic."""
+1 -7
View File
@@ -18,13 +18,10 @@ SOCKET_REP_REQ = "ipc:///tmp/cache/comms"
class InterProcessCommunicator(Communicator):
def __init__(self) -> None:
# bound eagerly so subprocesses starting before start_communicators()
# can still connect; their requests queue in zmq until the reader runs
self.context = zmq.Context()
self.socket = self.context.socket(zmq.REP)
self.socket.bind(SOCKET_REP_REQ)
self.stop_event: MpEvent = mp.Event()
self.reader_thread: threading.Thread | None = None
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""There is no communication back to the processes."""
@@ -32,8 +29,6 @@ class InterProcessCommunicator(Communicator):
def subscribe(self, receiver: Callable) -> None:
self._dispatcher = receiver
def start(self) -> None:
self.reader_thread = threading.Thread(target=self.read)
self.reader_thread.start()
@@ -66,8 +61,7 @@ class InterProcessCommunicator(Communicator):
def stop(self) -> None:
self.stop_event.set()
if self.reader_thread is not None:
self.reader_thread.join()
self.reader_thread.join()
self.socket.close(linger=0)
self.context.destroy(linger=0)
+174 -675
View File
@@ -1,38 +1,16 @@
from __future__ import annotations
import logging
import queue
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import Any
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from frigate.comms.base_communicator import Communicator
from frigate.config import FrigateConfig, birdseye_modes_to_mqtt_payload
if TYPE_CHECKING:
from frigate.comms.dispatcher import Dispatcher
from frigate.config import FrigateConfig
logger = logging.getLogger(__name__)
MQTT_LOOP_TIMEOUT = 1.0
MQTT_RECONNECT_INTERVAL = 10.0
MQTT_SHUTDOWN_FLUSH_TIMEOUT = 5.0
MQTT_ON_CONNECT_RATE_LIMIT = 1.0
MQTT_PUBLISH_WAIT_INTERVAL = 0.1
@dataclass(slots=True)
class QueuedPublish:
topic: str
payload: Any
retain: bool
done: threading.Event | None = None
class MqttClient(Communicator):
"""Frigate wrapper for mqtt client."""
@@ -41,80 +19,28 @@ class MqttClient(Communicator):
self.config = config
self.mqtt_config = config.mqtt
self.connected = False
self.client: mqtt.Client | None = None
self._dispatcher: Callable[[str, Any], Any] | None = None
self._command_router: Dispatcher | None = None
self._worker: threading.Thread | None = None
self._stop_event = threading.Event()
self._publish_queue: queue.Queue[QueuedPublish] = queue.Queue()
self._callback_queue: queue.Queue[tuple[Any, ...]] = queue.Queue()
self._retained_lock = threading.Lock()
self._pending_retained: dict[str, tuple[Any, bool]] = {}
self._inflight_retained: dict[int, tuple[str, Any]] = {}
self._subscription_mid: int | None = None
self._subscription_ready = False
self._next_connect_time = 0.0
self._last_on_connect_dispatch = 0.0
def subscribe(self, receiver: Callable) -> None:
"""Wrapper for allowing dispatcher to subscribe."""
self._dispatcher = receiver
def attach_dispatcher(self, dispatcher: Dispatcher) -> None:
"""Take Dispatcher's command surface and snapshot API."""
self._command_router = dispatcher
def start(self) -> None:
"""Start the MQTT worker after all receiver wiring is complete."""
if self._worker and self._worker.is_alive():
return
self._stop_event.clear()
self._start_worker()
self._start()
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state."""
full_topic = f"{self.mqtt_config.topic_prefix}/{topic}"
if not self.connected:
if retain:
self._queue_retained(full_topic, payload, retain)
else:
logger.debug("Unable to publish to %s: client is not connected", topic)
logger.debug(f"Unable to publish to {topic}: client is not connected")
return
self._publish_queue.put(QueuedPublish(full_topic, payload, retain))
self.client.publish(
f"{self.mqtt_config.topic_prefix}/{topic}",
payload,
qos=self.config.mqtt.qos,
retain=retain,
)
def stop(self) -> None:
if self._worker is None:
return
if self.connected and self._subscription_ready:
publish_done = threading.Event()
self._publish_queue.put(
QueuedPublish(
f"{self.mqtt_config.topic_prefix}/available",
"stopped",
True,
publish_done,
)
)
publish_done.wait(MQTT_SHUTDOWN_FLUSH_TIMEOUT)
self._stop_event.set()
if self.client is not None:
try:
self.client.disconnect()
except Exception:
logger.debug("MQTT disconnect raised during shutdown", exc_info=True)
if self._worker.is_alive():
self._worker.join(MQTT_SHUTDOWN_FLUSH_TIMEOUT + MQTT_LOOP_TIMEOUT)
self._cleanup_client()
self._worker = None
self.publish("available", "stopped", retain=True)
self.client.disconnect()
def _notifications_enabled_in_config(self) -> bool:
"""Whether notifications are configured globally or on any camera.
@@ -128,17 +54,17 @@ class MqttClient(Communicator):
for cam in self.config.cameras.values()
)
def _publish_retained_state(self) -> None:
"""Publish retained MQTT state after a successful subscribe."""
def _set_initial_topics(self) -> None:
"""Set initial state topics."""
for camera_name, camera in self.config.cameras.items():
self.publish(
f"{camera_name}/enabled/state",
"ON" if camera.enabled else "OFF",
"ON" if camera.enabled_in_config else "OFF",
retain=True,
)
self.publish(
f"{camera_name}/recordings/state",
"ON" if camera.record.enabled else "OFF",
"ON" if camera.record.enabled_in_config else "OFF",
retain=True,
)
self.publish(
@@ -148,7 +74,7 @@ class MqttClient(Communicator):
)
self.publish(
f"{camera_name}/audio/state",
"ON" if camera.audio.enabled else "OFF",
"ON" if camera.audio.enabled_in_config else "OFF",
retain=True,
)
self.publish(
@@ -163,7 +89,7 @@ class MqttClient(Communicator):
)
self.publish(
f"{camera_name}/motion/state",
"ON" if camera.motion.enabled else "OFF",
"ON",
retain=True,
)
self.publish(
@@ -173,7 +99,7 @@ class MqttClient(Communicator):
)
self.publish(
f"{camera_name}/ptz_autotracker/state",
"ON" if camera.onvif.autotracking.enabled else "OFF",
"ON" if camera.onvif.autotracking.enabled_in_config else "OFF",
retain=True,
)
self.publish(
@@ -197,9 +123,9 @@ class MqttClient(Communicator):
retain=True,
)
self.publish(
f"{camera_name}/birdseye_modes/state",
f"{camera_name}/birdseye_mode/state",
(
birdseye_modes_to_mqtt_payload(camera.birdseye.modes)
camera.birdseye.mode.value.upper()
if camera.birdseye.enabled
else "OFF"
),
@@ -207,22 +133,22 @@ class MqttClient(Communicator):
)
self.publish(
f"{camera_name}/review_alerts/state",
"ON" if camera.review.alerts.enabled else "OFF",
"ON" if camera.review.alerts.enabled_in_config else "OFF",
retain=True,
)
self.publish(
f"{camera_name}/review_detections/state",
"ON" if camera.review.detections.enabled else "OFF",
"ON" if camera.review.detections.enabled_in_config else "OFF",
retain=True,
)
self.publish(
f"{camera_name}/object_descriptions/state",
"ON" if camera.objects.genai.enabled else "OFF",
"ON" if camera.objects.genai.enabled_in_config else "OFF",
retain=True,
)
self.publish(
f"{camera_name}/review_descriptions/state",
"ON" if camera.review.genai.enabled else "OFF",
"ON" if camera.review.genai.enabled_in_config else "OFF",
retain=True,
)
@@ -263,521 +189,13 @@ class MqttClient(Communicator):
)
self.publish("available", "online", retain=True)
def _create_client(self) -> mqtt.Client:
"""Build a fresh paho client for a single connect attempt."""
client = mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id=self.mqtt_config.client_id,
reconnect_on_failure=False,
)
client.on_connect = self._on_connect
client.on_disconnect = self._on_disconnect
client.on_message = self._on_message
client.on_subscribe = self._on_subscribe
client.on_publish = self._on_publish
client.will_set(
self.mqtt_config.topic_prefix + "/available",
payload="offline",
qos=1,
retain=True,
)
if self.mqtt_config.tls_ca_certs is not None:
if (
self.mqtt_config.tls_client_cert is not None
and self.mqtt_config.tls_client_key is not None
):
client.tls_set(
self.mqtt_config.tls_ca_certs,
self.mqtt_config.tls_client_cert,
self.mqtt_config.tls_client_key,
)
else:
client.tls_set(self.mqtt_config.tls_ca_certs)
if self.mqtt_config.tls_insecure is not None:
client.tls_insecure_set(self.mqtt_config.tls_insecure)
if self.mqtt_config.user is not None:
client.username_pw_set(
self.mqtt_config.user,
password=self.mqtt_config.password,
)
return client
def _start_worker(self) -> None:
self._worker = threading.Thread(
target=self._worker_main, name="mqtt", daemon=True
)
self._worker.start()
logger.info("MQTT worker started")
def _worker_main(self) -> None:
"""Run the worker loop.
An unexpected crash disables MQTT for this session rather than taking
Frigate down with it, so it has to announce itself: without the offline
publish, consumers keep the last retained values and see a healthy
Frigate that has simply stopped updating.
"""
try:
self._mqtt_loop_worker()
except Exception:
if not self._stop_event.is_set():
logger.exception("MQTT worker crashed, disabling MQTT for this session")
self._stop_event.set()
self._subscription_ready = False
self._publish_offline_availability()
self.connected = False
finally:
# nothing drains the queue once the loop is gone, so release any
# waiter here or stop() blocks for the full flush timeout
self._requeue_disconnected_publishes()
self._cleanup_client()
def _publish_offline_availability(self) -> None:
"""Announce that MQTT is going away after a worker crash.
_cleanup_client() disconnects cleanly, which tells the broker to
suppress the will, so the retained topic would otherwise stay "online".
"""
if self.client is None:
return
try:
message_info = self.client.publish(
f"{self.mqtt_config.topic_prefix}/available",
"offline",
qos=self.config.mqtt.qos,
retain=True,
)
# pumped here rather than through _wait_for_publish() so the drain
# that may have just crashed is not re-entered
deadline = time.monotonic() + MQTT_SHUTDOWN_FLUSH_TIMEOUT
while not message_info.is_published() and time.monotonic() < deadline:
if (
self.client.loop(timeout=MQTT_PUBLISH_WAIT_INTERVAL)
!= mqtt.MQTT_ERR_SUCCESS
):
break
except Exception:
logger.warning(
"MQTT is dormant and the broker could not be told Frigate is offline",
exc_info=True,
)
def _mqtt_loop_worker(self) -> None:
# The worker owns all socket I/O so reconnect, subscribe, and publish
# ordering stays serialized in one place.
while not self._stop_event.is_set():
if self.client is None:
wait_time = self._next_connect_time - time.monotonic()
if wait_time > 0:
self._stop_event.wait(min(wait_time, MQTT_LOOP_TIMEOUT))
continue
if not self._connect_client():
self._next_connect_time = time.monotonic() + MQTT_RECONNECT_INTERVAL
continue
assert self.client is not None
try:
result = self.client.loop(timeout=MQTT_LOOP_TIMEOUT)
except (OSError, mqtt.WebsocketConnectionError) as err:
logger.warning("MQTT loop error: %s", err)
self._schedule_reconnect()
continue
self._drain_callback_queue()
self._drain_publish_queue()
if self._stop_event.is_set():
break
if result != mqtt.MQTT_ERR_SUCCESS and self.client is not None:
logger.error("MQTT loop returned error code: %s", result)
self._schedule_reconnect()
def _connect_client(self) -> bool:
"""Create and connect a new client instance owned by the worker thread."""
try:
self.client = self._create_client()
self.client.connect(self.mqtt_config.host, self.mqtt_config.port, 60)
except Exception as err:
logger.error("Unable to connect to MQTT server: %s", err)
self._cleanup_client()
return False
return True
def _cleanup_client(self) -> None:
"""Drop session-specific state and release the current paho client."""
self.connected = False
self._subscription_ready = False
self._subscription_mid = None
self._requeue_inflight_retained()
client = self.client
self.client = None
if client is None:
return
try:
client.disconnect()
except Exception:
logger.debug("MQTT client cleanup raised disconnect error", exc_info=True)
def _schedule_reconnect(self) -> None:
"""Tear down the current session and arm the next reconnect attempt."""
if self._stop_event.is_set():
return
self.connected = False
self._subscription_ready = False
self._subscription_mid = None
self._requeue_disconnected_publishes()
self._next_connect_time = time.monotonic() + MQTT_RECONNECT_INTERVAL
logger.info("MQTT reconnect scheduled in %.1fs", MQTT_RECONNECT_INTERVAL)
self._cleanup_client()
def _requeue_inflight_retained(self) -> None:
"""Rebuffer retained publishes paho took but the broker never acked.
Dropping the client drops paho's outbound queue with it, and the session
is clean, so the broker will not resume delivery on the new one.
"""
with self._retained_lock:
# mids are insertion ordered, so collapsing by topic keeps the
# newest value when several updates to one topic were in flight
latest = {
topic: payload for topic, payload in self._inflight_retained.values()
}
self._inflight_retained.clear()
for topic, payload in latest.items():
self._queue_retained(topic, payload, True, overwrite=False)
def _buffer_undelivered(
self, queued_publish: QueuedPublish, overwrite: bool = True
def on_mqtt_command(
self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage
) -> None:
"""Handle a publish that never reached the broker.
Releasing the waiter matters on every path: stop() blocks on it, so a
broker error would otherwise stall shutdown for the full flush timeout.
"""
if queued_publish.retain:
self._queue_retained(
queued_publish.topic,
queued_publish.payload,
queued_publish.retain,
overwrite=overwrite,
)
if queued_publish.done is not None:
queued_publish.done.set()
def _requeue_disconnected_publishes(self) -> None:
while True:
try:
queued_publish = self._publish_queue.get_nowait()
except queue.Empty:
break
self._buffer_undelivered(queued_publish)
def _drain_callback_queue(self) -> None:
# Paho callbacks only enqueue transport events; state transitions run
# here on the worker thread.
while True:
try:
event = self._callback_queue.get_nowait()
except queue.Empty:
break
event_type = event[0]
if event_type == "connect":
self._handle_connect_event(event[1])
elif event_type == "connect_failure":
self._handle_connect_failure(event[1])
elif event_type == "disconnect":
self._handle_disconnect_event(event[1])
elif event_type == "subscribed":
self._handle_subscribe_event(event[1], event[2])
elif event_type == "message":
self._handle_inbound_message(event[1], event[2])
elif event_type == "published":
self._handle_publish_event(event[1])
def _drain_publish_queue(self) -> None:
"""Publish queued work only after the session is fully subscribed.
Oldest first: the outage buffer replays before the queue, so a topic
that changed since the reconnect ends up on its newest value rather
than being reverted by the replay.
"""
if self.connected and not self._subscription_ready:
return
self._flush_pending_retained()
while True:
try:
queued_publish = self._publish_queue.get_nowait()
except queue.Empty:
break
if not self.connected:
self._buffer_undelivered(queued_publish)
continue
self._publish_direct(queued_publish)
def _flush_pending_retained(self) -> None:
"""Replay the latest retained state once the broker session is ready."""
if not self.connected or not self._subscription_ready:
return
with self._retained_lock:
pending = list(self._pending_retained.items())
self._pending_retained.clear()
for topic, (payload, retain) in pending:
self._publish_direct(QueuedPublish(topic, payload, retain))
def _publish_direct(self, queued_publish: QueuedPublish) -> None:
"""Publish a queued message from the worker thread's serialized context.
The waiter is released however this exits. The message is already off
the queue by now, so nothing else can recover it for a stop() that is
blocked waiting on it.
"""
try:
if self.client is None:
# never attempted, so anything already buffered for this topic
# was written later and has to survive
self._buffer_undelivered(queued_publish, overwrite=False)
return
try:
message_info = self.client.publish(
queued_publish.topic,
queued_publish.payload,
qos=self.config.mqtt.qos,
retain=queued_publish.retain,
)
except (OSError, mqtt.WebsocketConnectionError) as err:
logger.warning(
"MQTT publish failed for %s: %s", queued_publish.topic, err
)
# a newer buffered value for this topic wins over the failed one
self._buffer_undelivered(queued_publish, overwrite=False)
self._schedule_reconnect()
return
if message_info.rc != mqtt.MQTT_ERR_SUCCESS:
logger.error(
"Unable to publish to %s: mqtt error %s",
queued_publish.topic,
message_info.rc,
)
self._buffer_undelivered(queued_publish, overwrite=False)
self._schedule_reconnect()
return
# a successful rc only means paho accepted the message; above qos 0
# it is not durable until the broker acks, so keep a copy for replay
if queued_publish.retain and not message_info.is_published():
with self._retained_lock:
self._inflight_retained[message_info.mid] = (
queued_publish.topic,
queued_publish.payload,
)
if queued_publish.done is not None:
self._wait_for_publish(message_info)
finally:
if queued_publish.done is not None:
queued_publish.done.set()
def _handle_publish_event(self, mid: int) -> None:
"""Drop the replay copy once the broker has acknowledged the message."""
with self._retained_lock:
self._inflight_retained.pop(mid, None)
def _wait_for_publish(self, message_info: mqtt.MQTTMessageInfo) -> None:
"""Pump the loop until a shutdown-critical publish is acknowledged."""
deadline = time.monotonic() + MQTT_SHUTDOWN_FLUSH_TIMEOUT
while not message_info.is_published() and time.monotonic() < deadline:
if self.client is None:
return
try:
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()
return
self._drain_callback_queue()
if result != mqtt.MQTT_ERR_SUCCESS:
logger.error(
"MQTT loop returned error code while waiting for publish: %s",
result,
)
self._schedule_reconnect()
return
def _queue_retained(
self,
topic: str,
payload: Any,
retain: bool,
overwrite: bool = True,
) -> None:
"""Store the last retained value per topic for replay after reconnect."""
with self._retained_lock:
if overwrite or topic not in self._pending_retained:
self._pending_retained[topic] = (payload, retain)
def _handle_connect_event(self, reason_code: mqtt.ReasonCode) -> None: # type: ignore[name-defined]
"""Begin a new session by subscribing before any replay is published."""
if self.client is None:
return
self.connected = True
self._subscription_ready = False
self._subscription_mid = None
logger.debug("MQTT connected")
try:
result, mid = self.client.subscribe(
f"{self.mqtt_config.topic_prefix}/#",
qos=self.config.mqtt.qos,
)
except (OSError, mqtt.WebsocketConnectionError) as err:
logger.warning("MQTT subscribe failed: %s", err)
self._schedule_reconnect()
return
if result != mqtt.MQTT_ERR_SUCCESS:
logger.error(
"Unable to subscribe to MQTT command tree: mqtt error %s", result
)
self._schedule_reconnect()
return
self._subscription_mid = mid
def _handle_connect_failure(self, reason_code: mqtt.ReasonCode) -> None: # type: ignore[name-defined]
"""Record a failed connect attempt and transition into reconnect state."""
self.connected = False
logger.error(
"Unable to connect to MQTT server: %s", self._reason_code_name(reason_code)
self._dispatcher(
message.topic.replace(f"{self.mqtt_config.topic_prefix}/", "", 1),
message.payload.decode(),
)
self._schedule_reconnect()
def _handle_disconnect_event(self, reason_code: mqtt.ReasonCode) -> None: # type: ignore[name-defined]
"""Handle broker disconnects idempotently from the worker thread."""
if not self.connected:
return
self.connected = False
self._subscription_ready = False
self._subscription_mid = None
if self._stop_event.is_set():
logger.debug("MQTT disconnected")
self._cleanup_client()
return
logger.error("MQTT disconnected: %s", self._reason_code_name(reason_code))
self._schedule_reconnect()
def _handle_subscribe_event(
self,
mid: int,
reason_codes: list[mqtt.ReasonCode], # type: ignore[name-defined]
) -> None:
"""Mark the session ready after SUBACK, then replay retained/runtime state."""
if mid != self._subscription_mid:
return
if any(
getattr(reason_code, "is_failure", False) for reason_code in reason_codes
):
logger.error("MQTT subscription was rejected by the broker")
self._schedule_reconnect()
return
self._subscription_ready = True
self._subscription_mid = None
# a bug in replay should cost a snapshot, not the MQTT session
try:
self._publish_retained_state()
if self._command_router is not None:
self._command_router.publish_runtime_snapshot(self.publish)
except Exception:
logger.exception("Error replaying MQTT state after subscribe")
def _handle_inbound_message(self, topic: str, payload: str) -> None:
"""Forward supported command topics into Dispatcher semantics."""
if self._dispatcher is None:
return
if not self._is_supported_command_topic(topic):
return
if topic == "onConnect":
now = time.monotonic()
if now - self._last_on_connect_dispatch < MQTT_ON_CONNECT_RATE_LIMIT:
logger.debug("Skipping MQTT onConnect replay request due to rate limit")
return
self._last_on_connect_dispatch = now
# a raise here used to end the network thread and take MQTT down
try:
self._dispatcher(topic, payload)
except Exception:
logger.exception("Error handling MQTT command topic %s", topic)
def _is_supported_command_topic(self, topic: str) -> bool:
"""Filter the wildcard subscription down to Dispatcher's command surface.
Load-bearing rather than an optimization: the broker echoes Frigate's own
publishes back through frigate/#, and Dispatcher republishes topics it
does not recognize, so forwarding unfiltered would loop.
"""
if self._command_router is None:
return False
# mirrors the gate on the state topic in _publish_retained_state()
if topic == "notifications/set" and not self._notifications_enabled_in_config():
return False
return self._command_router.is_command_topic(topic)
def _strip_topic_prefix(self, topic: str) -> str:
return topic.replace(f"{self.mqtt_config.topic_prefix}/", "", 1)
def _is_success_reason_code(self, reason_code: mqtt.ReasonCode) -> bool: # type: ignore[name-defined]
if hasattr(reason_code, "is_failure"):
return not bool(reason_code.is_failure)
return bool(reason_code == 0)
def _reason_code_name(self, reason_code: mqtt.ReasonCode) -> str: # type: ignore[name-defined]
if hasattr(reason_code, "getName"):
return str(reason_code.getName())
return str(reason_code)
def _on_connect(
self,
@@ -787,11 +205,29 @@ class MqttClient(Communicator):
reason_code: mqtt.ReasonCode, # type: ignore[name-defined]
properties: Any,
) -> None:
"""Handle broker connect notifications from paho."""
if self._is_success_reason_code(reason_code):
self._callback_queue.put(("connect", reason_code))
else:
self._callback_queue.put(("connect_failure", reason_code))
"""Mqtt connection callback."""
threading.current_thread().name = "mqtt"
if reason_code != 0:
if reason_code == "Server unavailable":
logger.error(
"Unable to connect to MQTT server: MQTT Server unavailable"
)
elif reason_code == "Bad user name or password":
logger.error(
"Unable to connect to MQTT server: MQTT Bad username or password"
)
elif reason_code == "Not authorized":
logger.error("Unable to connect to MQTT server: MQTT Not authorized")
else:
logger.error(
"Unable to connect to MQTT server: Connection refused. Error code: %s",
reason_code.getName(),
)
self.connected = True
logger.debug("MQTT connected")
client.subscribe(f"{self.mqtt_config.topic_prefix}/#", qos=self.config.mqtt.qos)
self._set_initial_topics()
def _on_disconnect(
self,
@@ -801,63 +237,126 @@ class MqttClient(Communicator):
reason_code: mqtt.ReasonCode, # type: ignore[name-defined]
properties: Any,
) -> None:
"""Handle broker disconnect notifications from paho."""
self._callback_queue.put(("disconnect", reason_code))
"""Mqtt disconnection callback."""
self.connected = False
logger.error("MQTT disconnected")
def _on_subscribe(
self,
client: mqtt.Client,
userdata: Any,
mid: int,
reason_codes: list[mqtt.ReasonCode], # type: ignore[name-defined]
properties: Any,
) -> None:
"""Handle subscribe acknowledgements from paho."""
self._callback_queue.put(("subscribed", mid, reason_codes))
def _on_publish(
self,
client: mqtt.Client,
userdata: Any,
mid: int,
reason_code: mqtt.ReasonCode, # type: ignore[name-defined]
properties: Any,
) -> None:
"""Handle publish acknowledgements from paho.
Only tracked retained messages need an event. At the default qos 0
nothing is tracked, so this stays off the hot publish path.
"""
with self._retained_lock:
if mid not in self._inflight_retained:
return
self._callback_queue.put(("published", mid))
def _on_message(
self,
client: mqtt.Client,
userdata: Any,
message: mqtt.MQTTMessage,
) -> None:
"""Queue inbound MQTT messages for processing in the worker loop."""
topic = self._strip_topic_prefix(message.topic)
# Ignore everything outside Frigate's command surface before decoding or
# dispatching into the rest of the app.
if not self._is_supported_command_topic(topic):
return
try:
payload = message.payload.decode()
except UnicodeDecodeError:
logger.debug("Ignoring non-UTF-8 MQTT payload for topic %s", topic)
return
self._callback_queue.put(
(
"message",
topic,
payload,
)
def _start(self) -> None:
"""Start mqtt client."""
self.client = mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id=self.mqtt_config.client_id,
)
self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
self.client.will_set(
self.mqtt_config.topic_prefix + "/available",
payload="offline",
qos=1,
retain=True,
)
# register callbacks
callback_types = [
"enabled",
"recordings",
"snapshots",
"detect",
"audio",
"audio_transcription",
"motion",
"improve_contrast",
"ptz_autotracker",
"motion_threshold",
"motion_contour_area",
"birdseye",
"birdseye_mode",
"review_alerts",
"review_detections",
"object_descriptions",
"review_descriptions",
"notifications",
]
for name in self.config.cameras.keys():
for callback in callback_types:
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/{callback}/set",
self.on_mqtt_command,
)
# notifications suspend doesn't follow the /set topic pattern
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/notifications/suspend",
self.on_mqtt_command,
)
if self.config.cameras[name].onvif.host:
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/ptz",
self.on_mqtt_command,
)
for mask_name in self.config.cameras[name].motion.mask.keys():
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/motion_mask/{mask_name}/set",
self.on_mqtt_command,
)
for mask_name in self.config.cameras[name].objects.mask.keys():
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/object_mask/{mask_name}/set",
self.on_mqtt_command,
)
for zone_name in self.config.cameras[name].zones.keys():
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/zone/{zone_name}/set",
self.on_mqtt_command,
)
if self._notifications_enabled_in_config():
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/notifications/set",
self.on_mqtt_command,
)
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/profile/set",
self.on_mqtt_command,
)
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/onConnect", self.on_mqtt_command
)
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/restart", self.on_mqtt_command
)
if self.mqtt_config.tls_ca_certs is not None:
if (
self.mqtt_config.tls_client_cert is not None
and self.mqtt_config.tls_client_key is not None
):
self.client.tls_set(
self.mqtt_config.tls_ca_certs,
self.mqtt_config.tls_client_cert,
self.mqtt_config.tls_client_key,
)
else:
self.client.tls_set(self.mqtt_config.tls_ca_certs)
if self.mqtt_config.tls_insecure is not None:
self.client.tls_insecure_set(self.mqtt_config.tls_insecure)
if self.mqtt_config.user is not None:
self.client.username_pw_set(
self.mqtt_config.user, password=self.mqtt_config.password
)
try:
# https://stackoverflow.com/a/55390477
# with connect_async, retries are handled automatically
self.client.connect_async(self.mqtt_config.host, self.mqtt_config.port, 60)
self.client.loop_start()
except Exception as e:
logger.error(f"Unable to connect to MQTT server: {e}")
return
+1 -4
View File
@@ -18,10 +18,7 @@ class RecordingsDataTypeEnum(str, Enum):
class RecordingsDataPublisher(Publisher[Any]):
"""Publishes latest recording data.
Payloads are (camera, stream_type, timestamp, cache_path) on every topic.
"""
"""Publishes latest recording data."""
topic_base = "recordings/"
+16 -26
View File
@@ -63,8 +63,14 @@ class WebPushClient(Communicator):
self.last_notification_time: float = 0
self.user_cameras: dict[str, set[str]] = {}
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
self.notification_thread: threading.Thread | None = None
self.suspension_thread: threading.Thread | None = None
self.notification_thread = threading.Thread(
target=self._process_notifications, daemon=True
)
self.notification_thread.start()
self.suspension_thread = threading.Thread(
target=self._process_suspensions, daemon=True
)
self.suspension_thread.start()
if not self.config.notifications.email:
logger.warning("Email must be provided for push notifications to be sent.")
@@ -93,16 +99,6 @@ class WebPushClient(Communicator):
"""Wrapper for allowing dispatcher to subscribe."""
pass
def start(self) -> None:
self.notification_thread = threading.Thread(
target=self._process_notifications, daemon=True
)
self.notification_thread.start()
self.suspension_thread = threading.Thread(
target=self._process_suspensions, daemon=True
)
self.suspension_thread.start()
def check_registrations(self) -> None:
# check for valid claim or create new one
now = datetime.datetime.now().timestamp()
@@ -224,9 +220,7 @@ class WebPushClient(Communicator):
if topic == "reviews":
decoded = json.loads(payload)
camera = decoded["before"]["camera"]
camera_config = self.config.cameras.get(camera)
if camera_config is None or not camera_config.notifications.enabled:
if not self.config.cameras[camera].notifications.enabled:
return
if self.is_camera_suspended(camera):
logger.debug(f"Notifications for {camera} are currently suspended.")
@@ -240,14 +234,13 @@ class WebPushClient(Communicator):
# ensure notifications are enabled and the specific trigger has
# notification action enabled
camera_config = self.config.cameras.get(camera)
if (
camera_config is None
or not camera_config.notifications.enabled
or name not in camera_config.semantic_search.triggers
not self.config.cameras[camera].notifications.enabled
or name not in self.config.cameras[camera].semantic_search.triggers
or "notification"
not in camera_config.semantic_search.triggers[name].actions
not in self.config.cameras[camera]
.semantic_search.triggers[name]
.actions
):
return
@@ -258,9 +251,7 @@ class WebPushClient(Communicator):
elif topic == "camera_monitoring":
decoded = json.loads(payload)
camera = decoded["camera"]
camera_config = self.config.cameras.get(camera)
if camera_config is None or not camera_config.notifications.enabled:
if not self.config.cameras[camera].notifications.enabled:
return
if self.is_camera_suspended(camera):
logger.debug(f"Notifications for {camera} are currently suspended.")
@@ -611,5 +602,4 @@ class WebPushClient(Communicator):
def stop(self) -> None:
logger.info("Closing notification queue")
if self.notification_thread is not None:
self.notification_thread.join()
self.notification_thread.join()
+1
View File
@@ -466,6 +466,7 @@ class WebSocketClient(Communicator):
def subscribe(self, receiver: Callable) -> None:
self._dispatcher = receiver
self.start()
def start(self) -> None:
"""Start the websocket client."""
-5
View File
@@ -41,11 +41,6 @@ class AudioConfig(FrigateBaseModel):
title="Listen types",
description="List of audio event types to detect (for example: bark, fire_alarm, speech, yell).",
)
labelmap: dict[int, str] = Field(
default_factory=dict,
title="Audio labelmap customization",
description="Overrides or remapping entries to merge into the standard audio labelmap.",
)
filters: dict[str, AudioFilterConfig] | None = Field(
None,
title="Audio filters",
+16 -52
View File
@@ -9,57 +9,21 @@ __all__ = [
"BirdseyeConfig",
"BirdseyeLayoutConfig",
"BirdseyeModeEnum",
"birdseye_modes_from_mqtt_payload",
"birdseye_modes_to_mqtt_payload",
]
# canonical MQTT payload for an empty mode list
MQTT_NO_MODES = "NONE"
class BirdseyeModeEnum(str, Enum):
continuous = "continuous"
objects = "objects"
motion = "motion"
all_objects = "all_objects"
alerts = "alerts"
detections = "detections"
continuous = "continuous"
@classmethod
def get_index(cls, type):
return list(cls).index(type)
def birdseye_modes_from_mqtt_payload(payload: str) -> list[BirdseyeModeEnum] | None:
"""Parse an uppercase MQTT payload into activity modes, or None when invalid."""
raw_modes = payload.split(",")
if any(not raw_mode or raw_mode != raw_mode.upper() for raw_mode in raw_modes):
return None
if raw_modes == [MQTT_NO_MODES]:
return []
modes: list[BirdseyeModeEnum] = []
for raw_mode in raw_modes:
try:
mode = BirdseyeModeEnum(raw_mode.lower())
except ValueError:
return None
if mode in modes:
return None
modes.append(mode)
return modes
def birdseye_modes_to_mqtt_payload(modes: list[BirdseyeModeEnum]) -> str:
"""Serialize activity modes for MQTT state topics."""
payload = ",".join(mode.value.upper() for mode in BirdseyeModeEnum if mode in modes)
return payload or MQTT_NO_MODES
def default_birdseye_modes() -> list[BirdseyeModeEnum]:
"""Return the default Birdseye activity modes."""
return [BirdseyeModeEnum.all_objects]
@classmethod
def get(cls, index):
return list(cls)[index]
class BirdseyeLayoutConfig(FrigateBaseModel):
@@ -83,10 +47,10 @@ class BirdseyeConfig(FrigateBaseModel):
title="Enable Birdseye",
description="Enable or disable the Birdseye view feature.",
)
modes: list[BirdseyeModeEnum] = Field(
default_factory=default_birdseye_modes,
title="Activity types",
description="Activity types that include cameras in Birdseye.",
mode: BirdseyeModeEnum = Field(
default=BirdseyeModeEnum.objects,
title="Tracking mode",
description="Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.",
)
restream: bool = Field(
@@ -138,10 +102,10 @@ class BirdseyeCameraConfig(BaseModel):
title="Enable Birdseye",
description="Enable or disable the Birdseye view feature.",
)
modes: list[BirdseyeModeEnum] = Field(
default_factory=default_birdseye_modes,
title="Activity types",
description="Activity types that include cameras in Birdseye.",
mode: BirdseyeModeEnum = Field(
default=BirdseyeModeEnum.objects,
title="Tracking mode",
description="Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.",
)
order: int = Field(
+3 -35
View File
@@ -3,12 +3,7 @@ from enum import Enum
from pydantic import Field, PrivateAttr, model_validator
from frigate.const import (
CACHE_DIR,
CACHE_SEGMENT_FORMAT,
REGEX_CAMERA_NAME,
SUB_CACHE_TAG,
)
from frigate.const import CACHE_DIR, CACHE_SEGMENT_FORMAT, REGEX_CAMERA_NAME
from frigate.ffmpeg_presets import (
parse_preset_hardware_acceleration_decode,
parse_preset_hardware_acceleration_scale,
@@ -220,21 +215,16 @@ class CameraConfig(FrigateBaseModel):
# add roles to the input if there is only one
if len(config["ffmpeg"]["inputs"]) == 1:
existing_roles = config["ffmpeg"]["inputs"][0].get("roles", [])
has_audio = "audio" in config["ffmpeg"]["inputs"][0].get("roles", [])
config["ffmpeg"]["inputs"][0]["roles"] = [
"record",
"detect",
]
if "audio" in existing_roles:
if has_audio:
config["ffmpeg"]["inputs"][0]["roles"].append("audio")
# kept so role validation can report the real problem rather than
# claiming the role was never assigned
if "record_sub" in existing_roles:
config["ffmpeg"]["inputs"][0]["roles"].append("record_sub")
super().__init__(**config)
@property
@@ -304,28 +294,6 @@ class CameraConfig(FrigateBaseModel):
+ ffmpeg_output_args
)
if (
"record_sub" in ffmpeg_input.roles
and self.record.enabled
and self.record.sub.enabled
):
sub_output_args = self.ffmpeg.output_args.effective_record_sub
record_args = get_ffmpeg_arg_list(
parse_preset_output_record(
sub_output_args,
self.ffmpeg.apple_compatibility,
)
or sub_output_args
)
ffmpeg_output_args = (
record_args
+ [
f"{os.path.join(CACHE_DIR, self.name)}{SUB_CACHE_TAG}@{CACHE_SEGMENT_FORMAT}.mp4"
]
+ ffmpeg_output_args
)
# if there aren't any outputs enabled for this input
if len(ffmpeg_output_args) == 0:
return None
-7
View File
@@ -1,7 +1,5 @@
from pydantic import Field, model_validator
from frigate.detectors.detector_config import SceneEnum
from ..base import FrigateBaseModel
__all__ = ["DetectConfig", "StationaryConfig", "StationaryMaxFramesConfig"]
@@ -62,11 +60,6 @@ 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: 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 'all' run the model configured with a scene of 'all'.",
)
fps: int = Field(
default=5,
title="Detect FPS",
-15
View File
@@ -42,20 +42,6 @@ class FfmpegOutputArgsConfig(FrigateBaseModel):
title="Record output arguments",
description="Default output arguments for record role streams.",
)
record_sub: str | list[str] = Field(
default_factory=list,
title="Sub stream record output arguments",
description="Output arguments for record_sub role streams. The record output arguments are used when this is not set.",
)
@property
def effective_record_sub(self) -> str | list[str]:
"""Output arguments used for the record_sub role.
Falls back to the record arguments rather than to the stock preset so
that a customized record value keeps applying to both recorded streams.
"""
return self.record_sub or self.record
class FfmpegConfig(FrigateBaseModel):
@@ -113,7 +99,6 @@ class FfmpegConfig(FrigateBaseModel):
class CameraRoleEnum(str, Enum):
audio = "audio"
record = "record"
record_sub = "record_sub"
detect = "detect"
+1 -60
View File
@@ -2,7 +2,7 @@ from enum import Enum
from pydantic import Field
from frigate.const import MAX_PRE_CAPTURE, STREAM_TYPE_SUB
from frigate.const import MAX_PRE_CAPTURE
from frigate.review.types import SeverityEnum
from ..base import FrigateBaseModel
@@ -13,7 +13,6 @@ __all__ = [
"RecordExportConfig",
"RecordPreviewConfig",
"RecordQualityEnum",
"RecordSubConfig",
"EventsConfig",
"ReviewRetainConfig",
"RecordRetainConfig",
@@ -111,34 +110,6 @@ class RecordExportConfig(FrigateBaseModel):
)
class RecordSubConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
title="Enable sub stream recording",
description="Enable recording of a second, lower quality stream for adaptive quality playback and extended retention.",
)
continuous: RecordRetainConfig = Field(
default_factory=RecordRetainConfig,
title="Sub stream continuous retention",
description="Number of days to retain sub stream recordings regardless of tracked objects or motion.",
)
motion: RecordRetainConfig = Field(
default_factory=RecordRetainConfig,
title="Sub stream motion retention",
description="Number of days to retain sub stream recordings triggered by motion.",
)
alerts: ReviewRetainConfig = Field(
default_factory=ReviewRetainConfig,
title="Sub stream alert retention",
description="Retention settings for sub stream recordings of alerts.",
)
detections: ReviewRetainConfig = Field(
default_factory=ReviewRetainConfig,
title="Sub stream detection retention",
description="Retention settings for sub stream recordings of detections.",
)
class RecordConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
@@ -180,42 +151,12 @@ class RecordConfig(FrigateBaseModel):
title="Preview config",
description="Settings controlling the quality of recording previews shown in the UI.",
)
sub: RecordSubConfig = Field(
default_factory=RecordSubConfig,
title="Sub stream recording",
description="Settings for recording a second, lower quality stream.",
)
enabled_in_config: bool | None = Field(
default=None,
title="Original recording state",
description="Indicates whether recording was enabled in the original static configuration.",
)
def stream_enabled(self, stream_type: str) -> bool:
"""Whether the given record stream type should currently be recording."""
if stream_type == STREAM_TYPE_SUB:
return self.enabled and self.sub.enabled
return self.enabled
@property
def effective_alert_days(self) -> float:
"""Alert retention extended to the sub stream window when sub is enabled.
Review items and tracked objects must stay visible for as long as
either stream still has recordings.
"""
if self.sub.enabled:
return max(self.alerts.retain.days, self.sub.alerts.days)
return self.alerts.retain.days
@property
def effective_detection_days(self) -> float:
"""Detection retention extended to the sub window when sub is enabled."""
if self.sub.enabled:
return max(self.detections.retain.days, self.sub.detections.days)
return self.detections.retain.days
@property
def event_pre_capture(self) -> int:
return max(
+1 -7
View File
@@ -96,7 +96,6 @@ class CameraConfigUpdateSubscriber:
return
elif update_type == CameraConfigUpdateEnum.remove:
self.config.cameras.pop(camera, None)
self.config.drop_camera_model(camera)
self.camera_configs.pop(camera, None)
return
@@ -130,13 +129,8 @@ class CameraConfigUpdateSubscriber:
config.objects = updated_config
elif update_type == CameraConfigUpdateEnum.record:
old_enabled_in_config = config.record.enabled_in_config
old_sub_enabled = config.record.sub.enabled
config.record = updated_config
# the record and record_sub ffmpeg outputs are gated on these
if (
old_enabled_in_config != updated_config.enabled_in_config
or old_sub_enabled != updated_config.sub.enabled
):
if old_enabled_in_config != updated_config.enabled_in_config:
config.recreate_ffmpeg_cmds()
elif update_type == CameraConfigUpdateEnum.review:
config.review = updated_config
+68 -291
View File
@@ -11,6 +11,7 @@ from pydantic import (
BaseModel,
ConfigDict,
Field,
TypeAdapter,
ValidationInfo,
field_validator,
model_validator,
@@ -18,9 +19,8 @@ from pydantic import (
from ruamel.yaml import YAML
from frigate.const import REGEX_JSON
from frigate.detectors import ModelConfig
from frigate.detectors.detector_config import SceneEnum
from frigate.detectors.device import DeviceParseError, DeviceSpec, parse_device
from frigate.detectors import DetectorConfig, ModelConfig
from frigate.detectors.detector_config import BaseDetectorConfig
from frigate.plus import PlusApi
from frigate.util.builtin import (
deep_merge,
@@ -63,7 +63,7 @@ from .classification import (
SemanticSearchModelEnum,
)
from .database import DatabaseConfig
from .env import EnvVars, reload_sources
from .env import EnvVars
from .logger import LoggerConfig
from .mqtt import MqttConfig
from .network import NetworkingConfig
@@ -79,14 +79,9 @@ logger = logging.getLogger(__name__)
yaml = YAML()
# Pydantic field default applied when an existing config omits `models:`.
# Pydantic field default applied when an existing config omits `detectors:`.
# Kept as cpu tflite for backwards compatibility with 0.17 configs.
DEFAULT_MODELS = [{"devices": ["cpu"]}]
def _default_models() -> list[ModelConfig]:
return [ModelConfig.model_validate(model) for model in DEFAULT_MODELS]
DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}}
# Used by the openvino branch below and rendered into the new-config YAML
# template so first-time setups default to openvino on CPU.
@@ -98,7 +93,7 @@ DEFAULT_MODEL = {
"path": "/openvino-model/ssdlite_mobilenet_v2.xml",
"labelmap_path": "/openvino-model/coco_91cl_bkgr.txt",
}
NEW_CONFIG_MODELS = [{"devices": ["openvino:CPU"], **DEFAULT_MODEL}]
NEW_CONFIG_DETECTORS = {"ov": {"type": "openvino", "device": "CPU"}}
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
@@ -114,7 +109,7 @@ DEFAULT_CONFIG = f"""
mqtt:
enabled: False
{_render_default_yaml({"models": NEW_CONFIG_MODELS})}
{_render_default_yaml({"detectors": NEW_CONFIG_DETECTORS, "model": DEFAULT_MODEL})}
cameras: {{}} # No cameras defined, UI wizard should be used
version: {CURRENT_CONFIG_VERSION}
"""
@@ -260,21 +255,6 @@ def verify_config_roles(camera_config: CameraConfig) -> None:
f"Camera {camera_config.name} has record enabled, but record is not assigned to an input."
)
if (
camera_config.record.enabled
and camera_config.record.sub.enabled
and "record_sub" not in assigned_roles
):
raise ValueError(
f"Camera {camera_config.name} has sub stream recording enabled, but record_sub is not assigned to an input."
)
for ffmpeg_input in camera_config.ffmpeg.inputs:
if "record" in ffmpeg_input.roles and "record_sub" in ffmpeg_input.roles:
raise ValueError(
f"Camera {camera_config.name} has record and record_sub assigned to the same input, which would record the same stream twice."
)
if camera_config.audio.enabled and "audio" not in assigned_roles:
raise ValueError(
f"Camera {camera_config.name} has audio events enabled, but audio is not assigned to an input."
@@ -295,11 +275,13 @@ def verify_valid_live_stream_names(
)
def verify_record_output_args_segment_time(
camera_config: CameraConfig, output_args: str | list[str], role: str
def verify_recording_segments_setup_with_reasonable_time(
camera_config: CameraConfig,
) -> None:
"""Verify that a recording role's output args segment at a reasonable time."""
record_args: list[str] = get_ffmpeg_arg_list(output_args)
"""Verify that recording segments are setup and segment time is not greater than 60."""
record_args: list[str] = get_ffmpeg_arg_list(
camera_config.ffmpeg.output_args.record
)
if record_args[0].startswith("preset"):
return
@@ -309,32 +291,16 @@ def verify_record_output_args_segment_time(
except ValueError:
raise ValueError(
f"Camera {camera_config.name} has no segment_time in \
{role} output args, segment args are required for record."
recording output args, segment args are required for record."
) from None
if int(record_args[seg_arg_index + 1]) > 60:
raise ValueError(
f"Camera {camera_config.name} has invalid segment_time in {role} output args, \
f"Camera {camera_config.name} has invalid segment_time output arg, \
segment_time must be 60 or less."
)
def verify_recording_segments_setup_with_reasonable_time(
camera_config: CameraConfig,
) -> None:
"""Verify that recording segments are setup and segment time is not greater than 60."""
verify_record_output_args_segment_time(
camera_config, camera_config.ffmpeg.output_args.record, "recording"
)
if camera_config.record.sub.enabled:
verify_record_output_args_segment_time(
camera_config,
camera_config.ffmpeg.output_args.effective_record_sub,
"sub stream recording",
)
def verify_zone_objects_are_tracked(camera_config: CameraConfig) -> None:
"""Verify that user has not entered zone objects that are not in the tracking config."""
for zone_name, zone in camera_config.zones.items():
@@ -531,11 +497,16 @@ class FrigateConfig(FrigateBaseModel):
description="User interface preferences such as timezone, time/date formatting, and units.",
)
# Detection model config
models: list[ModelConfig] = Field(
default_factory=_default_models,
title="Detection models",
description="Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene.",
# Detector config
detectors: dict[str, BaseDetectorConfig] = Field(
default=DEFAULT_DETECTORS,
title="Detector hardware",
description="Configuration for object detectors (CPU, GPU, ONNX backends) and any detector-specific model settings.",
)
model: ModelConfig = Field(
default_factory=ModelConfig,
title="Detection model",
description="Settings to configure a custom object detection model and its input shape.",
)
# GenAI config (named provider configs: name -> GenAIConfig)
@@ -650,226 +621,11 @@ class FrigateConfig(FrigateBaseModel):
)
_plus_api: PlusApi
_model_devices: dict[SceneEnum, list[DeviceSpec]]
_camera_models: dict[str, ModelConfig]
_all_attributes: list[str]
_all_attribute_logos: list[str]
_all_attributes_map: dict[str, list[str]]
_all_labels: set[str]
@property
def plus_api(self) -> PlusApi:
return self._plus_api
@property
def all_attributes(self) -> list[str]:
"""Every attribute label across all configured models."""
return self._all_attributes
@property
def all_attribute_logos(self) -> list[str]:
"""Every logo attribute label across all configured models."""
return self._all_attribute_logos
@property
def all_attributes_map(self) -> dict[str, list[str]]:
"""Object label to attribute labels, merged across all configured models."""
return self._all_attributes_map
@property
def all_labels(self) -> set[str]:
"""Every object label across all configured models."""
return self._all_labels
@property
def primary_model(self) -> ModelConfig:
"""The model used when no specific camera is in play."""
for model in self.models:
if model.scene == SceneEnum.all:
return model
return self.models[0]
def model_for_camera(self, camera_name: str) -> ModelConfig:
"""Get the detection model a camera runs on.
Cameras added at runtime (wizard, clone, debug replay) are inserted
into cameras after parse, so they miss the cache built during
post_validation and are resolved here on first lookup.
Args:
camera_name: Name of the camera
Returns:
The model matching the camera's detect scene
"""
model = self._camera_models.get(camera_name)
if model is None:
camera = self.cameras.get(camera_name)
scene = camera.detect.scene if camera is not None else SceneEnum.all
model = self._resolve_camera_model(camera_name, scene)
self._camera_models[camera_name] = model
return model
def drop_camera_model(self, camera_name: str) -> None:
"""Forget the cached model for a camera removed at runtime.
A later re-add resolves fresh, so a camera recreated under the same
name with a different detect scene doesn't inherit the removed
camera's model.
Args:
camera_name: Name of the removed camera
"""
self._camera_models.pop(camera_name, None)
def devices_for_model(self, model: ModelConfig) -> list[DeviceSpec]:
"""Get the parsed hardware devices a model runs on.
Args:
model: One of the configured models
Returns:
The parsed device specs, in config order
"""
return self._model_devices[model.scene]
def _load_model(self, model: ModelConfig, detector: str) -> ModelConfig:
"""Apply detector specific defaults to a model and load its weights and labels.
Args:
model: The configured model
detector: The detector type the model runs on
Returns:
The loaded model
"""
model_config = model.model_dump(exclude_unset=True, warnings="none")
if "path" not in model_config:
if detector == "cpu" or detector.endswith("_tfl"):
model_config["path"] = "/cpu_model.tflite"
elif detector == "edgetpu":
model_config["path"] = "/edgetpu_model.tflite"
elif detector == "openvino":
for default_key, default_value in DEFAULT_MODEL.items():
model_config.setdefault(default_key, default_value)
loaded = ModelConfig.model_validate(model_config)
loaded.check_and_load_plus_model(self.plus_api, detector)
loaded.compute_model_hash()
return loaded
def _load_models(self) -> None:
"""Validate the configured models and load each one."""
if not self.models:
raise ValueError("At least one model must be configured under models")
model_devices: dict[SceneEnum, list[DeviceSpec]] = {}
# device string -> the scene of the model that already claimed it
claimed_devices: dict[str, SceneEnum] = {}
for index, model in enumerate(self.models):
scene = model.scene.value
if model.scene in model_devices:
raise ValueError(
f"Multiple models are configured with a scene of '{scene}'. Each model must use a different scene."
)
if not model.devices:
raise ValueError(
f"Model '{scene}' must list at least one entry under devices."
)
try:
devices = [parse_device(device) for device in model.devices]
except DeviceParseError as err:
raise ValueError(
f"Model '{scene}' has an invalid device: {err}"
) from err
detectors = {device.detector for device in devices}
if len(detectors) > 1:
raise ValueError(
f"Model '{scene}' mixes the {', '.join(sorted(detectors))} detectors. All of a model's devices must use the same detector."
)
for device in devices:
if device.raw in claimed_devices and not device.shareable:
other = claimed_devices[device.raw]
where = (
f"twice by model '{scene}'"
if other == model.scene
else f"by both the '{other.value}' and '{scene}' models"
)
raise ValueError(
f"Device '{device.raw}' is used {where}, but it can only run one detection process."
)
claimed_devices[device.raw] = model.scene
self.models[index] = self._load_model(model, devices[0].detector)
model_devices[model.scene] = devices
attributes: set[str] = set()
attribute_logos: set[str] = set()
attributes_map: dict[str, set[str]] = {}
labels: set[str] = set()
for model in self.models:
attributes.update(model.all_attributes)
attribute_logos.update(model.all_attribute_logos)
labels.update(model.merged_labelmap.values())
for label, label_attributes in model.attributes_map.items():
attributes_map.setdefault(label, set()).update(label_attributes)
self._model_devices = model_devices
self._all_attributes = sorted(attributes)
self._all_attribute_logos = sorted(attribute_logos)
self._all_attributes_map = {
label: sorted(label_attributes)
for label, label_attributes in sorted(attributes_map.items())
}
self._all_labels = labels
def _resolve_camera_model(self, name: str, scene: SceneEnum) -> ModelConfig:
"""Resolve which model a camera runs on.
A camera may name a scene no model is configured for, which is valid as
long as an 'all' model is there to fall back to.
Args:
name: Name of the camera
scene: The camera's detect scene, which defaults to 'all'
Returns:
The model the camera runs on
"""
by_scene = {model.scene: model for model in self.models}
model = by_scene.get(scene)
if model is not None:
return model
default = by_scene.get(SceneEnum.all)
if default is None:
raise ValueError(
f"Camera '{name}' has a detect scene of '{scene.value}', but no model is configured for that scene or for 'all'."
)
logger.warning(
"Camera '%s' has a detect scene of '%s', but no model is configured for that scene, so the 'all' model is used",
name,
scene.value,
)
return default
@model_validator(mode="after")
def post_validation(self, info: ValidationInfo) -> Self:
# Load plus api from context, if possible.
@@ -914,10 +670,8 @@ class FrigateConfig(FrigateBaseModel):
"'embeddings' in its roles for semantic search."
)
self._load_models()
# set default min_score for object attributes
for attribute in self.all_attributes:
for attribute in self.model.all_attributes:
existing = self.objects.filters.get(attribute)
if existing is None:
self.objects.filters[attribute] = FilterConfig(min_score=0.7)
@@ -967,7 +721,44 @@ class FrigateConfig(FrigateBaseModel):
exclude_unset=True,
)
self._camera_models = {}
for key, detector in self.detectors.items():
adapter = TypeAdapter(DetectorConfig)
model_dict = (
detector
if isinstance(detector, dict)
else detector.model_dump(warnings="none")
)
detector_config: BaseDetectorConfig = adapter.validate_python(model_dict)
# users should not set model themselves
if detector_config.model:
logger.warning(
"The model key should be specified at the root level of the config, not under detectors. The nested model key will be ignored."
)
detector_config.model = None
model_config = self.model.model_dump(exclude_unset=True, warnings="none")
if detector_config.model_path:
model_config["path"] = detector_config.model_path
if "path" not in model_config:
if detector_config.type == "cpu" or detector_config.type.endswith(
"_tfl"
):
model_config["path"] = "/cpu_model.tflite"
elif detector_config.type == "edgetpu":
model_config["path"] = "/edgetpu_model.tflite"
elif detector_config.type == "openvino":
for default_key, default_value in DEFAULT_MODEL.items():
model_config.setdefault(default_key, default_value)
model = ModelConfig.model_validate(model_config)
model.check_and_load_plus_model(self.plus_api, detector_config.type)
model.compute_model_hash()
labelmap_objects = model.merged_labelmap.values()
detector_config.model = model
self.detectors[key] = detector_config
for name, camera in self.cameras.items():
modified_global_config = global_config.copy()
@@ -994,9 +785,6 @@ class FrigateConfig(FrigateBaseModel):
{"name": name, **merged_config}
)
camera_model = self._resolve_camera_model(name, camera_config.detect.scene)
self._camera_models[name] = camera_model
if camera_config.ffmpeg.hwaccel_args == "auto":
camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args
@@ -1217,7 +1005,7 @@ class FrigateConfig(FrigateBaseModel):
verify_profile_overrides_match_base(camera_config)
verify_autotrack_zones(camera_config)
verify_motion_and_detect(camera_config)
verify_objects_track(camera_config, camera_model.merged_labelmap.values())
verify_objects_track(camera_config, labelmap_objects)
verify_lpr_and_face(self, camera_config)
# Validate camera profiles reference top-level profile definitions
@@ -1234,16 +1022,8 @@ class FrigateConfig(FrigateBaseModel):
config.name = name
self.objects.parse_all_objects(self.cameras)
# every model shares one colormap so a label is drawn the same color no
# matter which model detected it, so filter attributes across all models
# rather than letting each model filter with only its own
colored_labels = sorted(
set(self.objects.all_objects) - set(self.all_attributes)
)
for model in self.models:
model.create_colormap(colored_labels)
self.model.create_colormap(sorted(self.objects.all_objects))
self.model.check_and_load_plus_model(self.plus_api)
# Check audio transcription and audio detection requirements
if self.audio_transcription.enabled:
@@ -1313,9 +1093,6 @@ class FrigateConfig(FrigateBaseModel):
@classmethod
def parse(cls, config, *, is_json=None, safe_load=False, **context):
# Pick up secrets.yaml edits without a restart.
reload_sources()
# If config is a file, read its contents.
if hasattr(config, "read"):
fname = getattr(config, "name", None)
+18 -192
View File
@@ -1,193 +1,20 @@
"""Environment variable and secrets handling for the Frigate config."""
import logging
import os
import re
from collections.abc import Mapping
from pathlib import Path
from typing import Annotated, Any
from typing import Annotated
from pydantic import AfterValidator, ValidationInfo
from ruamel.yaml import YAML, YAMLError
from frigate.const import CONFIG_DIR
logger = logging.getLogger(__name__)
class UnknownVariableError(ValueError):
"""Undefined {FRIGATE_*} placeholder. ValueError so pydantic names the field."""
# Substitution sources, lowest precedence first.
_CONFIG_ENV_VARS: dict[str, str] = {}
_SECRETS_FILE: dict[str, str] = {}
# Snapshot: apply_config_env_vars() writes os.environ after import.
_CONTAINER_ENV: dict[str, str] = {
k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")
}
_CREDENTIALS_DIR: dict[str, str] = {}
_SOURCES: tuple[tuple[str, dict[str, str]], ...] = (
("environment_vars config block", _CONFIG_ENV_VARS),
("secrets.yaml", _SECRETS_FILE),
("container environment", _CONTAINER_ENV),
("credentials directory", _CREDENTIALS_DIR),
)
FRIGATE_ENV_VARS: dict[str, str] = {}
_WARNED_COLLISIONS: set[str] = set()
def _rebuild(warn: bool = True) -> None:
"""Merge the sources into FRIGATE_ENV_VARS.
warn=False is for the import-time call, before logging is configured.
"""
merged: dict[str, str] = {}
origin: dict[str, str] = {}
duplicated: set[str] = set()
for label, source in _SOURCES:
for key, value in source.items():
if key in merged and merged[key] != value:
duplicated.add(key)
merged[key] = value
origin[key] = label
if warn:
for key in sorted(duplicated - _WARNED_COLLISIONS):
_WARNED_COLLISIONS.add(key)
logger.warning(
"%s is defined in more than one place, using the value from %s",
key,
origin[key],
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
secrets_dir = os.environ.get("CREDENTIALS_DIRECTORY", "/run/secrets")
# read secret files as env vars too
if os.path.isdir(secrets_dir) and os.access(secrets_dir, os.R_OK):
for secret_file in os.listdir(secrets_dir):
if secret_file.startswith("FRIGATE_"):
FRIGATE_ENV_VARS[secret_file] = (
Path(os.path.join(secrets_dir, secret_file)).read_text().strip()
)
# In place: tests hold a reference to this dict.
FRIGATE_ENV_VARS.clear()
FRIGATE_ENV_VARS.update(merged)
def _load_credentials_dir() -> dict[str, str]:
"""Read FRIGATE_* files from the Docker or systemd credentials directory."""
directory = os.environ.get("CREDENTIALS_DIRECTORY", "/run/secrets")
values: dict[str, str] = {}
if not (os.path.isdir(directory) and os.access(directory, os.R_OK)):
return values
for name in os.listdir(directory):
if not name.startswith("FRIGATE_"):
continue
try:
values[name] = Path(os.path.join(directory, name)).read_text().strip()
except (OSError, UnicodeDecodeError):
logger.warning("Unable to read %s in %s, skipping", name, directory)
return values
def _secrets_file_path() -> str | None:
"""Locate secrets.yaml next to the config file."""
config_file = os.environ.get("CONFIG_FILE")
config_dir = os.path.dirname(config_file) if config_file else CONFIG_DIR
for name in ("secrets.yaml", "secrets.yml"):
path = os.path.join(config_dir, name)
if os.path.isfile(path):
return path
return None
def _load_secrets_file() -> dict[str, str]:
"""Read the flat FRIGATE_* map from secrets.yaml, if it exists."""
path = _secrets_file_path()
if path is None:
return {}
try:
with open(path) as f:
raw: Any = YAML(typ="safe").load(f)
except OSError as err:
raise ValueError(f"Unable to read {path}: {err.strerror}") from err
except YAMLError as err:
# The parser message can quote values, so only name a position.
mark = getattr(err, "problem_mark", None)
where = f" near line {mark.line + 1}" if mark is not None else ""
raise ValueError(f"{path} is not valid YAML{where}") from err
if raw is None:
return {}
if not isinstance(raw, dict):
raise ValueError(f"{path} must be a flat map of names to values")
values: dict[str, str] = {}
for key, value in raw.items():
name = str(key)
if isinstance(value, (dict, list)):
raise ValueError(f"{path} value for {name} must be a single value")
if not name.startswith("FRIGATE_"):
logger.warning(
"Ignoring %s in %s, names must start with FRIGATE_", name, path
)
continue
values[name] = "" if value is None else str(value)
return values
def reload_sources(warn: bool = True) -> None:
"""Re-read the file backed sources and rebuild the namespace."""
_CREDENTIALS_DIR.clear()
_CREDENTIALS_DIR.update(_load_credentials_dir())
try:
secrets = _load_secrets_file()
except ValueError as err:
# Keep the last good values; this runs at import and on every parse.
logger.error("Ignoring secrets file, %s", err)
else:
_SECRETS_FILE.clear()
_SECRETS_FILE.update(secrets)
_rebuild(warn)
def apply_config_env_vars(values: Mapping[str, object]) -> None:
"""Install the environment_vars block as the lowest priority source.
Unprefixed keys only set os.environ.
"""
for key, value in values.items():
resolved = str(value)
if key.startswith("FRIGATE_"):
_CONFIG_ENV_VARS[key] = resolved
else:
os.environ[key] = resolved
_rebuild()
# Export the winning value; auth reads FRIGATE_JWT_SECRET from os.environ.
for key in values:
if key.startswith("FRIGATE_"):
os.environ[key] = FRIGATE_ENV_VARS[key]
reload_sources(warn=False)
# Matches a FRIGATE_* identifier following an opening brace.
_FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+")
@@ -202,13 +29,12 @@ def substitute_frigate_vars(value: str) -> str:
* `{{` and `}}` collapse to literal `{` / `}` (the documented escape).
* `{FRIGATE_NAME}` is replaced from `FRIGATE_ENV_VARS`; an unknown name
raises `UnknownVariableError` to preserve the existing "Invalid
substitution" error path.
raises `KeyError` to preserve the existing "Invalid substitution"
error path.
* A `{` that begins `{FRIGATE_` but is not a well-formed
`{FRIGATE_NAME}` placeholder raises `ValueError` (malformed
placeholder). Callers that catch `UnknownVariableError` to allow
unknown-var passthrough will still surface malformed syntax as an
error.
placeholder). Callers that catch `KeyError` to allow unknown-var
passthrough will still surface malformed syntax as an error.
* Any other `{` or `}` is treated as a literal and passed through.
"""
out: list[str] = []
@@ -232,10 +58,7 @@ def substitute_frigate_vars(value: str) -> str:
):
key = ident_match.group(0)
if key not in FRIGATE_ENV_VARS:
raise UnknownVariableError(
f"{key} is not defined in the environment, "
"secrets.yaml, or the environment_vars config"
)
raise KeyError(key)
out.append(FRIGATE_ENV_VARS[key])
i = ident_match.end() + 1
continue
@@ -271,7 +94,10 @@ EnvString = Annotated[str, AfterValidator(validate_env_string)]
def validate_env_vars(v: dict[str, str], info: ValidationInfo) -> dict[str, str]:
if isinstance(info.context, dict) and info.context.get("install", False):
apply_config_env_vars(v)
for k, val in v.items():
os.environ[k] = val
if k.startswith("FRIGATE_"):
FRIGATE_ENV_VARS[k] = val
return v
+2 -7
View File
@@ -8,7 +8,6 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from frigate.config.camera.birdseye import birdseye_modes_to_mqtt_payload
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdatePublisher,
@@ -43,12 +42,8 @@ SECTION_STATE_TOPICS: dict[str, list[tuple[str, Callable[[Any], Any]]]] = {
"birdseye": [
("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"),
(
"birdseye_modes",
lambda c: (
birdseye_modes_to_mqtt_payload(c.birdseye.modes)
if c.birdseye.enabled
else "OFF"
),
"birdseye_mode",
lambda c: c.birdseye.mode.value.upper() if c.birdseye.enabled else "OFF",
),
],
"detect": [("detect", lambda c: "ON" if c.detect.enabled else "OFF")],
-9
View File
@@ -23,15 +23,6 @@ SHM_FRAMES_VAR = "SHM_MAX_FRAMES"
REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"
# Stream type constants
STREAM_TYPE_MAIN = "main"
STREAM_TYPE_SUB = "sub"
SUB_CACHE_TAG = "@sub"
RECORD_STREAM_TYPES = (STREAM_TYPE_MAIN, STREAM_TYPE_SUB)
ROLE_TO_STREAM_TYPE = {"record": STREAM_TYPE_MAIN, "record_sub": STREAM_TYPE_SUB}
STREAM_TYPE_TO_ROLE = {v: k for k, v in ROLE_TO_STREAM_TYPE.items()}
# Attribute & Object constants
DEFAULT_ATTRIBUTE_LABEL_MAP = {
@@ -72,7 +72,7 @@ class LicensePlateProcessingMixin:
# Object config
self.lp_objects: list[str] = []
for obj, attributes in self.config.all_attributes_map.items():
for obj, attributes in self.config.model.attributes_map.items():
if "license_plate" in attributes:
self.lp_objects.append(obj)
@@ -83,10 +83,6 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
"""
event_id = data["event_id"]
camera_name = data["camera"]
camera_config = self.config.cameras.get(camera_name)
if camera_config is None:
return
if data_type == PostProcessDataEnum.recording:
start_ts = data["frame_time"]
@@ -108,7 +104,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
try:
audio_data = get_audio_from_recording(
camera_config.ffmpeg,
self.config.cameras[camera_name].ffmpeg,
camera_name,
start_ts,
end_ts,
@@ -151,12 +151,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
logger.error(f"Event {event_id} not found for description regeneration")
return
camera_config = self.config.cameras.get(str(event.camera))
if camera_config is None:
logger.error("Camera %s no longer exists", event.camera)
return
camera_config = self.config.cameras[str(event.camera)]
if not camera_config.objects.genai.enabled and not force:
logger.error(f"GenAI not enabled for camera {event.camera}")
return
@@ -23,7 +23,6 @@ from frigate.const import (
ATTRIBUTE_LABEL_DISPLAY_MAP,
CACHE_DIR,
CLIPS_DIR,
STREAM_TYPE_MAIN,
UPDATE_REVIEW_DESCRIPTION,
)
from frigate.data_processing.types import PostProcessDataEnum
@@ -138,10 +137,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
return
camera = data["after"]["camera"]
camera_config = self.config.cameras.get(camera)
if camera_config is None:
return
camera_config = self.config.cameras[camera]
if not camera_config.review.genai.enabled:
return
@@ -235,8 +231,8 @@ class ReviewDescriptionProcessor(PostProcessorApi):
final_data,
thumbs,
camera_config.review.genai,
sorted(self.config.all_labels),
self.config.all_attributes,
list(self.config.model.merged_labelmap.values()),
self.config.model.all_attributes,
),
).start()
@@ -442,7 +438,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
)
.where((ts >= Recordings.start_time) & (ts <= Recordings.end_time))
.where(Recordings.camera == camera)
.where(Recordings.stream_type == STREAM_TYPE_MAIN)
.order_by(Recordings.start_time.desc())
.limit(1)
.get()
+5 -34
View File
@@ -3,7 +3,7 @@ import json
import logging
import os
from enum import Enum
from typing import Any, ClassVar
from typing import Any
import requests
from pydantic import BaseModel, ConfigDict, Field
@@ -15,9 +15,6 @@ from frigate.util.builtin import generate_color_palette, load_labels
logger = logging.getLogger(__name__)
# attributes that are recognized rather than shown as a logo
NON_LOGO_ATTRIBUTES = ["face", "license_plate"]
class PixelFormatEnum(str, Enum):
rgb = "rgb"
@@ -47,27 +44,7 @@ class ModelTypeEnum(str, Enum):
yologeneric = "yolo-generic"
class SceneEnum(str, Enum):
"""The camera environment a detection model is intended for."""
all = "all"
indoor = "indoor"
outdoor = "outdoor"
indoor_thermal = "indoor_thermal"
outdoor_thermal = "outdoor_thermal"
class ModelConfig(BaseModel):
scene: SceneEnum = Field(
default=SceneEnum.all,
title="Model scene",
description="The camera environment this model is used for. Cameras select a model by setting detect.scene to a matching value, and 'all' is used by any camera that does not set one.",
)
devices: list[str] = Field(
default_factory=list,
title="Detection hardware",
description="Hardware this model runs on, as '<detector>' or '<detector>:<device>' (for example 'edgetpu:pci:0' or 'openvino:GPU'). Listing the same device more than once runs additional inference processes on it.",
)
path: str | None = Field(
None,
title="Custom object detector model path",
@@ -134,7 +111,7 @@ class ModelConfig(BaseModel):
@property
def non_logo_attributes(self) -> list[str]:
return NON_LOGO_ATTRIBUTES
return ["face", "license_plate"]
@property
def all_attributes(self) -> list[str]:
@@ -224,7 +201,9 @@ class ModelConfig(BaseModel):
unique_attributes.update(attributes)
self._all_attributes = list(unique_attributes)
self._all_attribute_logos = list(unique_attributes - set(NON_LOGO_ATTRIBUTES))
self._all_attribute_logos = list(
unique_attributes - set(["face", "license_plate"])
)
self._merged_labelmap = {
**{int(key): val for key, val in model_info["labelMap"].items()},
@@ -255,14 +234,6 @@ class ModelConfig(BaseModel):
class BaseDetectorConfig(BaseModel):
# how the trailing part of a device string ("openvino:GPU" -> "GPU") maps onto
# this detector's fields, and whether the same device may be listed more than
# once to run additional inference processes against it. Most accelerators
# multiplex fine, so this is opt-out rather than opt-in.
device_spec_field: ClassVar[str] = "device"
device_spec_type: ClassVar[type] = str
shareable: ClassVar[bool] = True
# the type field must be defined in all subclasses
type: str = Field(
default="cpu",
+1 -19
View File
@@ -2,7 +2,7 @@ import importlib
import logging
import pkgutil
from enum import Enum
from typing import Annotated, Union, get_args
from typing import Annotated, Union
from pydantic import Field
@@ -39,21 +39,3 @@ DetectorConfig = Annotated[
Union[tuple(BaseDetectorConfig.__subclasses__())], # noqa: UP007
Field(discriminator="type"),
]
def _discriminator_value(config_class: type[BaseDetectorConfig]) -> str | None:
"""Read the Literal value of a detector config class' type field."""
field = config_class.model_fields.get("type")
if field is None:
return None
values = get_args(field.annotation)
return values[0] if values else None
config_types: dict[str, type[BaseDetectorConfig]] = {
key: config_class
for config_class in BaseDetectorConfig.__subclasses__()
if (key := _discriminator_value(config_class)) is not None
}
-113
View File
@@ -1,113 +0,0 @@
"""Parsing of detection hardware device strings."""
import logging
from dataclasses import dataclass
from pydantic import TypeAdapter, ValidationError
from frigate.detectors.detector_config import BaseDetectorConfig, ModelConfig
from frigate.detectors.detector_types import DetectorConfig, config_types
logger = logging.getLogger(__name__)
_detector_adapter: TypeAdapter[BaseDetectorConfig] = TypeAdapter(DetectorConfig)
@dataclass(frozen=True)
class DeviceSpec:
"""A parsed `<detector>` or `<detector>:<device>` string."""
raw: str
detector: str
device: str | None
@property
def shareable(self) -> bool:
"""Whether this device may be listed more than once."""
return config_types[self.detector].shareable
class DeviceParseError(ValueError):
pass
def parse_device(raw: str) -> DeviceSpec:
"""Parse a device string into its detector type and detector specific device.
Args:
raw: The configured device string, for example 'edgetpu:pci:0'
Returns:
The parsed spec
Raises:
DeviceParseError: If the detector type is unknown or the device is not
valid for that detector
"""
detector, separator, device = raw.partition(":")
if detector not in config_types:
raise DeviceParseError(
f"'{raw}' does not name a known detector. Available detectors are {', '.join(sorted(config_types))}"
)
spec = DeviceSpec(raw=raw, detector=detector, device=device if separator else None)
# surface a bad device now rather than when the detection process starts
build_detector_config(spec, None)
return spec
def build_detector_config(
spec: DeviceSpec, model: ModelConfig | None
) -> BaseDetectorConfig:
"""Build the detector config a device string describes.
Args:
spec: The parsed device spec
model: The model this detector runs, if it has been resolved yet
Returns:
The validated detector config
Raises:
DeviceParseError: If the device is not valid for this detector type
"""
config: dict[str, object] = {"type": spec.detector, "model": model}
if spec.device is not None:
config_class = config_types[spec.detector]
try:
config[config_class.device_spec_field] = config_class.device_spec_type(
spec.device
)
except ValueError as err:
raise DeviceParseError(
f"'{spec.raw}' is not a valid {spec.detector} device: {err}"
) from err
try:
return _detector_adapter.validate_python(config)
except ValidationError as err:
raise DeviceParseError(f"'{spec.raw}' is not a valid device: {err}") from err
def runner_names(devices: list[DeviceSpec]) -> list[str]:
"""Build a unique name for each device, since a shareable device may repeat.
Args:
devices: Every device spec across every configured model, in config order
Returns:
A name per device, suffixed with '#2', '#3', etc. on repeats
"""
names: list[str] = []
seen: dict[str, int] = {}
for spec in devices:
count = seen.get(spec.raw, 0) + 1
seen[spec.raw] = count
names.append(spec.raw if count == 1 else f"{spec.raw}#{count}")
return names
-368
View File
@@ -1,368 +0,0 @@
"""Discovery of object detection hardware attached to the system.
Every probe here is a filesystem read. Nothing shells out, initializes a
runtime, or opens a device, so this is cheap enough to run from the API process
while detector children hold the hardware.
Hardware is reported whether or not this image ships a detector that can drive
it. Matching hardware to an image is a separate concern.
"""
import logging
import os
from glob import glob
from pydantic import BaseModel, Field
from frigate.const import SUPPORTED_RK_SOCS
from frigate.detectors.detector_types import config_types
from frigate.util.services import enumerate_drm_devices
logger = logging.getLogger(__name__)
# roots the probes read from, so tests can point them at a fixture tree
SYS_ROOT = "/sys"
DEV_ROOT = "/dev"
PROC_ROOT = "/proc"
ETC_ROOT = "/etc"
# a Coral reports as Global Unichip until its firmware is loaded, then as Google
CORAL_USB_IDS = {("1a6e", "089a"), ("18d1", "9302")}
INTEL_DRM_DRIVERS = ("i915", "xe")
AMD_DRM_DRIVERS = ("amdgpu",)
class HardwareUnit(BaseModel):
"""One physical piece of hardware."""
device: str = Field(
title="Device string",
description="The value to put in a model's devices list, for example 'edgetpu:pci:1'.",
)
label: str = Field(
title="Unit label",
description="How to identify this unit among others of the same kind, for example 'PCIe 1'.",
)
class DetectionHardware(BaseModel):
"""A kind of detection hardware, and every unit of it that was found."""
key: str = Field(
title="Hardware key",
description="Stable identifier for this kind of hardware.",
)
detector: str = Field(
title="Detector type",
description="The detector that drives this hardware.",
)
name: str = Field(
title="Hardware name",
description="Human readable name for this kind of hardware.",
)
units: list[HardwareUnit] = Field(
title="Units",
description="Each physical piece of this hardware that was found.",
)
count: int = Field(
title="Unit count",
description="How many units were found.",
)
unlimited: bool = Field(
title="Unlimited detectors",
description="Whether this hardware can run more inference processes than there are units.",
)
def _read(path: str) -> str | None:
"""Read a small file, returning None if it cannot be read."""
try:
with open(path) as f:
return f.read().strip()
except OSError:
return None
def _is_shareable(detector: str) -> bool:
"""Whether a detector lets the same device run more than one process."""
config_class = config_types.get(detector)
# a detector missing from this image is assumed to behave like most of them
return config_class.shareable if config_class else True
def _hardware(
key: str, detector: str, name: str, units: list[HardwareUnit]
) -> DetectionHardware:
return DetectionHardware(
key=key,
detector=detector,
name=name,
units=units,
count=len(units),
unlimited=_is_shareable(detector),
)
def detect_coral_pci() -> DetectionHardware | None:
"""Find PCIe and M.2 Coral accelerators, which register as apex devices."""
names = sorted(
os.path.basename(path) for path in glob(f"{SYS_ROOT}/class/apex/apex_*")
)
if not names:
return None
units = [
HardwareUnit(device=f"edgetpu:pci:{index}", label=f"PCIe {index}")
for index in range(len(names))
]
return _hardware("edgetpu:pci", "edgetpu", "Coral EdgeTPU (PCIe)", units)
def detect_coral_usb() -> DetectionHardware | None:
"""Find USB Coral accelerators by their USB vendor and product ids."""
found = 0
for device_dir in sorted(glob(f"{SYS_ROOT}/bus/usb/devices/*")):
vendor = _read(os.path.join(device_dir, "idVendor"))
product = _read(os.path.join(device_dir, "idProduct"))
if vendor and product and (vendor.lower(), product.lower()) in CORAL_USB_IDS:
found += 1
if not found:
return None
units = [
HardwareUnit(device=f"edgetpu:usb:{index}", label=f"USB {index}")
for index in range(found)
]
return _hardware("edgetpu:usb", "edgetpu", "Coral EdgeTPU (USB)", units)
def _drm_devices(drivers: tuple[str, ...]) -> list[str]:
"""PCI addresses of DRM devices bound to one of the given drivers."""
return sorted(
pdev for pdev, driver in enumerate_drm_devices().items() if driver in drivers
)
def detect_intel_gpu() -> DetectionHardware | None:
"""Find Intel GPUs through their DRM driver."""
pdevs = _drm_devices(INTEL_DRM_DRIVERS)
if not pdevs:
return None
# OpenVINO reports a lone GPU as "GPU" and enumerates them as GPU.0, GPU.1
# only when there is more than one
if len(pdevs) == 1:
units = [HardwareUnit(device="openvino:GPU", label=pdevs[0])]
else:
units = [
HardwareUnit(device=f"openvino:GPU.{index}", label=pdev)
for index, pdev in enumerate(pdevs)
]
return _hardware("openvino:GPU", "openvino", "Intel GPU", units)
def detect_intel_npu() -> DetectionHardware | None:
"""Find Intel NPUs, which register as accel devices bound to intel_vpu."""
units = []
for accel_path in sorted(glob(f"{SYS_ROOT}/class/accel/accel*")):
try:
driver = os.path.basename(os.readlink(f"{accel_path}/device/driver"))
except OSError:
continue
if driver != "intel_vpu":
continue
units.append(
HardwareUnit(device="openvino:NPU", label=os.path.basename(accel_path))
)
if not units:
return None
# OpenVINO has no way to address a specific NPU, so only the first is usable
return _hardware("openvino:NPU", "openvino", "Intel NPU", units[:1])
def detect_amd_gpu() -> DetectionHardware | None:
"""Find AMD GPUs through their DRM driver."""
pdevs = _drm_devices(AMD_DRM_DRIVERS)
if not pdevs:
return None
# ROCm runs through onnx, whose MIGraphX provider takes no device index, so
# only one is addressable
units = [HardwareUnit(device="onnx", label=pdevs[0])]
return _hardware("onnx:amd", "onnx", "AMD GPU", units)
def detect_nvidia_gpu() -> DetectionHardware | None:
"""Find discrete Nvidia GPUs through the nvidia driver's proc entries."""
units = []
for index, gpu_dir in enumerate(sorted(glob(f"{PROC_ROOT}/driver/nvidia/gpus/*"))):
information = _read(os.path.join(gpu_dir, "information")) or ""
name = f"GPU {index}"
for line in information.splitlines():
if line.startswith("Model:"):
name = line.split(":", 1)[1].strip()
break
units.append(HardwareUnit(device=f"onnx:{index}", label=name))
if not units:
return None
# the model name is more useful as the hardware name when there is only one
name = units[0].label if len(units) == 1 else "NVIDIA GPU"
return _hardware("onnx:nvidia", "onnx", name, units)
def detect_jetson() -> DetectionHardware | None:
"""Find an Nvidia Jetson, whose integrated GPU runs through tensorrt."""
is_jetson = os.path.isfile(f"{ETC_ROOT}/nv_tegra_release") or os.path.exists(
f"{SYS_ROOT}/devices/gpu.0/load"
)
if not is_jetson:
return None
units = [HardwareUnit(device="tensorrt:0", label="Integrated GPU")]
return _hardware("tensorrt", "tensorrt", "NVIDIA Jetson", units)
def _dev_units(pattern: str, device: str, label: str) -> list[HardwareUnit]:
"""Build units from device nodes matching a glob."""
return [
HardwareUnit(device=device.format(index=index), label=f"{label} {index}")
for index in range(len(glob(f"{DEV_ROOT}/{pattern}")))
]
def detect_hailo() -> DetectionHardware | None:
"""Find Hailo accelerators by their device nodes."""
nodes = sorted(glob(f"{DEV_ROOT}/hailo*"))
if not nodes:
return None
# the hailo runtime schedules across every attached device itself, so there
# is nothing to address individually
units = [HardwareUnit(device="hailo8l:PCIe", label=os.path.basename(nodes[0]))]
return _hardware("hailo8l", "hailo8l", "Hailo", units)
def detect_memryx() -> DetectionHardware | None:
"""Find MemryX accelerators by their device nodes."""
units = _dev_units("memx*", "memryx:PCIe:{index}", "PCIe")
if not units:
return None
return _hardware("memryx", "memryx", "MemryX MX3", units)
def detect_rockchip() -> DetectionHardware | None:
"""Find a Rockchip NPU by reading the SoC from the device tree."""
compatible = _read(f"{PROC_ROOT}/device-tree/compatible")
if not compatible:
return None
soc = compatible.split(",")[-1].strip("\x00")
if soc not in SUPPORTED_RK_SOCS:
return None
units = [HardwareUnit(device="rknn", label=soc.upper())]
return _hardware("rknn", "rknn", f"Rockchip NPU ({soc.upper()})", units)
def detect_axengine() -> DetectionHardware | None:
"""Find an AXERA accelerator by its control device node."""
if not os.path.exists(f"{DEV_ROOT}/axcl_host"):
return None
units = [HardwareUnit(device="axengine", label="AXERA")]
return _hardware("axengine", "axengine", "AXERA NPU", units)
def detect_synaptics() -> DetectionHardware | None:
"""Find a Synaptics NPU by its device node."""
if not os.path.exists(f"{DEV_ROOT}/synap"):
return None
units = [HardwareUnit(device="synaptics", label="Synaptics")]
return _hardware("synaptics", "synaptics", "Synaptics NPU", units)
def detect_cpu() -> DetectionHardware:
"""The CPU, which is always available."""
units = [HardwareUnit(device="cpu", label="CPU")]
return _hardware("cpu", "cpu", "CPU", units)
# ordered so accelerators are offered ahead of the CPU fallback
PROBES = (
detect_coral_pci,
detect_coral_usb,
detect_hailo,
detect_memryx,
detect_intel_npu,
detect_intel_gpu,
detect_nvidia_gpu,
detect_jetson,
detect_amd_gpu,
detect_rockchip,
detect_axengine,
detect_synaptics,
detect_cpu,
)
class HardwareProber:
"""Probes for detection hardware, caching the result for the process."""
_hardware: list[DetectionHardware] | None = None
def probe(self, refresh: bool = False) -> list[DetectionHardware]:
"""Get the detection hardware attached to this system.
Args:
refresh: Probe again instead of using the cached result
Returns:
Every kind of detection hardware that was found
"""
if self._hardware is not None and not refresh:
return self._hardware
found = []
for probe in PROBES:
try:
hardware = probe()
except Exception:
logger.warning("Failed to probe for %s", probe.__name__, exc_info=True)
continue
if hardware is not None:
found.append(hardware)
logger.debug("Detected hardware: %s", [h.key for h in found])
self._hardware = found
return found
hardware_prober = HardwareProber()
+1 -4
View File
@@ -1,5 +1,5 @@
import logging
from typing import ClassVar, Literal
from typing import Literal
from pydantic import ConfigDict, Field
@@ -27,9 +27,6 @@ class CpuDetectorConfig(BaseDetectorConfig):
title="CPU",
)
device_spec_field: ClassVar[str] = "num_threads"
device_spec_type: ClassVar[type] = int
type: Literal[DETECTOR_KEY]
num_threads: int = Field(
default=3,
+1 -4
View File
@@ -1,7 +1,7 @@
import logging
import math
import os
from typing import ClassVar, Literal
from typing import Literal
import cv2
import numpy as np
@@ -28,9 +28,6 @@ class EdgeTpuDetectorConfig(BaseDetectorConfig):
title="EdgeTPU",
)
# a TPU can only be opened by one process
shareable: ClassVar[bool] = False
type: Literal[DETECTOR_KEY]
device: str = Field(
default=None,
+1 -4
View File
@@ -5,7 +5,7 @@ import shutil
import urllib.request
import zipfile
from queue import Queue
from typing import ClassVar, Literal
from typing import Literal
import cv2
import numpy as np
@@ -37,9 +37,6 @@ class MemryXDetectorConfig(BaseDetectorConfig):
title="MemryX",
)
# an accelerator can only be opened by one process
shareable: ClassVar[bool] = False
type: Literal[DETECTOR_KEY]
device: str = Field(
default="PCIe",
+1 -1
View File
@@ -28,7 +28,7 @@ class OvDetectorConfig(BaseDetectorConfig):
type: Literal[DETECTOR_KEY]
device: str = Field(
default="AUTO",
default=None,
title="Device Type",
description="The device to use for OpenVINO inference (e.g. 'CPU', 'GPU', 'NPU').",
)
+1 -4
View File
@@ -2,7 +2,7 @@ import logging
import os.path
import re
import urllib.request
from typing import ClassVar, Literal
from typing import Literal
import cv2
import numpy as np
@@ -35,9 +35,6 @@ class RknnDetectorConfig(BaseDetectorConfig):
title="RKNN",
)
device_spec_field: ClassVar[str] = "num_cores"
device_spec_type: ClassVar[type] = int
type: Literal[DETECTOR_KEY]
num_cores: int = Field(
default=0,
+1 -3
View File
@@ -14,7 +14,7 @@ try:
except ModuleNotFoundError:
TRT_SUPPORT = False
from typing import ClassVar, Literal
from typing import Literal
from pydantic import ConfigDict, Field
@@ -53,8 +53,6 @@ class TensorRTDetectorConfig(BaseDetectorConfig):
title="TensorRT",
)
device_spec_type: ClassVar[type] = int
type: Literal[DETECTOR_KEY]
device: int = Field(
default=0, title="GPU Device Index", description="The GPU device index to use."
+2 -23
View File
@@ -609,18 +609,6 @@ class EmbeddingMaintainer(threading.Thread):
# Embed the thumbnail
self._embed_thumbnail(event_id, thumbnail)
# every post processor below reads config.cameras[camera], but
# tracked_events still has to be released or the thumbnails held
# for this event leak, same as the two exits above
if camera not in self.config.cameras:
logger.debug("Skipping post processing for removed camera %s", camera)
for processor in self.post_processors:
if isinstance(processor, ObjectDescriptionProcessor):
processor.cleanup_event(event_id)
continue
# call any defined post processors
for processor in self.post_processors:
if isinstance(processor, LicensePlatePostProcessor):
@@ -678,18 +666,11 @@ class EmbeddingMaintainer(threading.Thread):
to_remove = []
for id, data in self.detected_license_plates.items():
camera_config = self.config.cameras.get(data["camera"])
if camera_config is None:
# camera was removed, drop the entry rather than expiring it
to_remove.append(id)
continue
last_seen = data.get("last_seen", 0)
if not last_seen:
continue
if now - last_seen > camera_config.lpr.expire_time:
if now - last_seen > self.config.cameras[data["camera"]].lpr.expire_time:
to_remove.append(id)
for id in to_remove:
self.event_metadata_publisher.publish(
@@ -714,9 +695,7 @@ class EmbeddingMaintainer(threading.Thread):
topic = str(raw_topic)
if topic.endswith(RecordingsDataTypeEnum.saved.value):
camera, _stream_type, recordings_available_through_timestamp, _ = (
payload
)
camera, recordings_available_through_timestamp, _ = payload
self.recordings_available_through[camera] = (
recordings_available_through_timestamp
+7 -29
View File
@@ -210,11 +210,7 @@ class AudioEventMaintainer(threading.Thread):
# per-camera stop signal so a single maintainer can be torn down at
# runtime (e.g. on camera removal) without stopping the whole process
self.camera_stop_event = threading.Event()
self.detector = AudioTfl(
stop_event,
self.camera_config.audio.num_threads,
self.camera_config.audio.labelmap,
)
self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads)
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
self.logger = logging.getLogger(f"audio.{self.camera_config.name}")
@@ -396,10 +392,7 @@ class AudioEventMaintainer(threading.Thread):
while not self.stop_event.is_set() and not self.camera_stop_event.is_set():
# check if there is an updated config
updated_topics = self.config_subscriber.check_for_updates()
if CameraConfigUpdateEnum.audio.name in updated_topics:
self.detector.update_labelmap(self.camera_config.audio.labelmap)
self.config_subscriber.check_for_updates()
enabled = self.camera_config.enabled
if enabled != self.was_enabled:
@@ -458,17 +451,10 @@ class AudioEventMaintainer(threading.Thread):
class AudioTfl:
def __init__(
self,
stop_event: threading.Event,
num_threads: int = 2,
labelmap: dict[int, str] | None = None,
) -> None:
def __init__(self, stop_event: threading.Event, num_threads: int = 2) -> None:
self.stop_event = stop_event
self.num_threads = num_threads
self._default_labels = load_labels("/audio-labelmap.txt", prefill=521)
self.labels: dict[int, str] = {}
self.update_labelmap(labelmap or {})
self.labels = load_labels("/audio-labelmap.txt", prefill=521)
# Suppress TFLite delegate creation messages that bypass Python logging
with suppress_stderr_during("tflite_interpreter_init"):
self.interpreter = Interpreter(
@@ -480,10 +466,6 @@ class AudioTfl:
self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details()
def update_labelmap(self, labelmap: dict[int, str]) -> None:
"""Merge configured label overrides into the default audio labelmap."""
self.labels = {**self._default_labels, **labelmap}
def _detect_raw(self, tensor_input: np.ndarray) -> np.ndarray:
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
self.interpreter.invoke()
@@ -522,14 +504,10 @@ class AudioTfl:
raw_detections = self._detect_raw(tensor_input)
detected_labels: set[str] = set()
for d in raw_detections:
if d[1] < threshold:
break
label = self.labels[int(d[0])]
if label in detected_labels:
continue
detected_labels.add(label)
detections.append((label, float(d[1]), (d[2], d[3], d[4], d[5])))
detections.append(
(self.labels[int(d[0])], float(d[1]), (d[2], d[3], d[4], d[5]))
)
return detections
Loaded 100 of 264 files, more files were not shown because too many files have changed in this diff. Show more