mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-15 11:12:49 +03:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4dca2bbd | ||
|
|
3109e7539e | ||
|
|
2347f954bb | ||
|
|
2638729c56 | ||
|
|
7a49eb4bbb | ||
|
|
468258a7c3 | ||
|
|
c1f9896443 | ||
|
|
01bb9f3f37 | ||
|
|
b91fb05314 | ||
|
|
199bea081c | ||
|
|
07ba2357e6 | ||
|
|
79ea68caa2 | ||
|
|
5c9c02002f | ||
|
|
7b42d94bfe | ||
|
|
0f5ed8822d | ||
|
|
af537b9479 | ||
|
|
2395a82639 | ||
|
|
e8c7f4b2ff | ||
|
|
f7afec3aa7 | ||
|
|
d67304a84d | ||
|
|
8de6216c61 | ||
|
|
80e0bbeda6 | ||
|
|
4147d01374 | ||
|
|
a9d09f8a81 | ||
|
|
fe14d4ef09 | ||
|
|
079bd802f2 | ||
|
|
163d3b865e | ||
|
|
ca6d327f74 | ||
|
|
8700227704 | ||
|
|
ad79e666eb | ||
|
|
fc79aeab5e | ||
|
|
b1cdf1f76b | ||
|
|
fc319f4223 | ||
|
|
bf35e90bc8 | ||
|
|
07abbd2c0a | ||
|
|
694d162071 | ||
|
|
b1d9676638 | ||
|
|
891a0df879 | ||
|
|
2d5845c770 | ||
|
|
612a7cb871 | ||
|
|
101e5d0e98 | ||
|
|
bcff29c35b | ||
|
|
467404b410 | ||
|
|
767597967e | ||
|
|
57b8206e86 | ||
|
|
86b828f52e | ||
|
|
4b39edf983 | ||
|
|
06f5229567 | ||
|
|
90a33f504c | ||
|
|
d0766aa3ee | ||
|
|
76a5e00bd5 | ||
|
|
b914f32cea | ||
|
|
48acba8dab | ||
|
|
c605295483 | ||
|
|
ad35bf49f7 | ||
|
|
000bf4a03b | ||
|
|
d2982bd144 | ||
|
|
2cd53ccdfe | ||
|
|
2ba33e227c | ||
|
|
036bae4ea9 | ||
|
|
8384a8c5b3 | ||
|
|
77fc2ce174 | ||
|
|
8425a76558 |
@@ -42,6 +42,89 @@ jobs:
|
|||||||
tags: ${{ steps.setup.outputs.image-name }}-amd64
|
tags: ${{ steps.setup.outputs.image-name }}-amd64
|
||||||
cache-from: type=registry,ref=${{ steps.setup.outputs.cache-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
|
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:
|
arm64_build:
|
||||||
runs-on: ubuntu-22.04-arm
|
runs-on: ubuntu-22.04-arm
|
||||||
name: ARM Build
|
name: ARM Build
|
||||||
|
|||||||
+25
-3
@@ -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 \
|
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
|
/deps/build_intel_media_driver.sh
|
||||||
|
|
||||||
FROM scratch AS go2rtc
|
FROM wget AS go2rtc
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
WORKDIR /rootfs/usr/local/go2rtc/bin
|
RUN --mount=type=bind,source=docker/main/install_go2rtc.sh,target=/deps/install_go2rtc.sh \
|
||||||
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.14/go2rtc_linux_${TARGETARCH}" go2rtc
|
/deps/install_go2rtc.sh
|
||||||
|
|
||||||
FROM wget AS tempio
|
FROM wget AS tempio
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@@ -265,6 +265,23 @@ 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 \
|
RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_deps.sh \
|
||||||
/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 DEFAULT_FFMPEG_VERSION="8.0"
|
||||||
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0"
|
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0"
|
||||||
|
|
||||||
@@ -307,6 +324,11 @@ HEALTHCHECK --start-period=300s --start-interval=5s --interval=15s --timeout=5s
|
|||||||
# Frigate deps with Node.js and NPM for devcontainer
|
# Frigate deps with Node.js and NPM for devcontainer
|
||||||
FROM deps AS 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
|
# 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
|
# But start a fake service for simulating the logs
|
||||||
COPY docker/main/fake_frigate_run /etc/s6-overlay/s6-rc.d/frigate/run
|
COPY docker/main/fake_frigate_run /etc/s6-overlay/s6-rc.d/frigate/run
|
||||||
|
|||||||
+77
-37
@@ -28,7 +28,13 @@ update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
|
|||||||
mkdir -p -m 600 /root/.gnupg
|
mkdir -p -m 600 /root/.gnupg
|
||||||
|
|
||||||
# install coral runtime
|
# 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"
|
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
|
unset DEBIAN_FRONTEND
|
||||||
yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive
|
yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive
|
||||||
rm /tmp/libedgetpu1-max.deb
|
rm /tmp/libedgetpu1-max.deb
|
||||||
@@ -45,36 +51,41 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
|
|||||||
fi
|
fi
|
||||||
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
|
# ffmpeg -> amd64
|
||||||
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||||
mkdir -p /usr/lib/ffmpeg/5.0
|
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"
|
||||||
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"
|
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"
|
||||||
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
|
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"
|
||||||
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
|
fi
|
||||||
|
|
||||||
# ffmpeg -> arm64
|
# ffmpeg -> arm64
|
||||||
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||||
mkdir -p /usr/lib/ffmpeg/5.0
|
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"
|
||||||
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"
|
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"
|
||||||
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
|
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"
|
||||||
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
|
fi
|
||||||
|
|
||||||
# arch specific packages
|
# arch specific packages
|
||||||
@@ -120,27 +131,56 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
|
|||||||
apt-get -qq install -y libtbb12
|
apt-get -qq install -y libtbb12
|
||||||
|
|
||||||
# install legacy and standard intel compute packages
|
# 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
|
# see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info
|
||||||
# needed core package
|
# needed core package
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
|
fetch_intel_deb 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
|
dpkg -i libigdgmm12_22.9.0_amd64.deb
|
||||||
rm libigdgmm12_22.9.0_amd64.deb
|
rm libigdgmm12_22.9.0_amd64.deb
|
||||||
|
|
||||||
# legacy compute-runtime packages
|
# legacy compute-runtime packages
|
||||||
wget 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-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
|
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
|
||||||
wget 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-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
|
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
|
||||||
# standard compute-runtime packages
|
# standard compute-runtime packages
|
||||||
wget 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/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
|
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
|
||||||
wget 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-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
|
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
|
||||||
# npu packages
|
# npu packages
|
||||||
wget 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/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
|
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
|
||||||
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
|
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
|
||||||
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
|
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
|
||||||
|
|
||||||
dpkg -i *.deb
|
dpkg -i *.deb
|
||||||
rm *.deb
|
rm *.deb
|
||||||
|
|||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/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"
|
||||||
@@ -4,11 +4,29 @@ set -euxo pipefail
|
|||||||
|
|
||||||
hailo_version="4.21.0"
|
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
|
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||||
arch="x86_64"
|
arch="x86_64"
|
||||||
elif [[ "${TARGETARCH}" == "arm64" ]]; then
|
elif [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||||
arch="aarch64"
|
arch="aarch64"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz" | tar -C / -xzf -
|
# downloaded rather than streamed into tar because streaming and verifying the
|
||||||
wget -P /wheels/ "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
|
# 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 -
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ set -euxo pipefail
|
|||||||
|
|
||||||
s6_version="3.2.1.0"
|
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
|
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||||
s6_arch="x86_64"
|
s6_arch="x86_64"
|
||||||
elif [[ "${TARGETARCH}" == "arm64" ]]; then
|
elif [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||||
@@ -12,8 +21,15 @@ fi
|
|||||||
|
|
||||||
mkdir -p /rootfs/
|
mkdir -p /rootfs/
|
||||||
|
|
||||||
wget -qO- "https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-noarch.tar.xz" |
|
download_and_extract() {
|
||||||
tar -C /rootfs/ -Jxpf -
|
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-${s6_arch}.tar.xz" |
|
download_and_extract "noarch"
|
||||||
tar -C /rootfs/ -Jxpf -
|
download_and_extract "${s6_arch}"
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ set -euxo pipefail
|
|||||||
|
|
||||||
tempio_version="2021.09.0"
|
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
|
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||||
arch="amd64"
|
arch="amd64"
|
||||||
elif [[ "${TARGETARCH}" == "arm64" ]]; then
|
elif [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||||
@@ -13,4 +21,5 @@ fi
|
|||||||
mkdir -p /rootfs/usr/local/tempio/bin
|
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}"
|
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
|
chmod 755 /rootfs/usr/local/tempio/bin/tempio
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
#!/command/with-contenv bash
|
#!/command/with-contenv bash
|
||||||
# shellcheck shell=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
|
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
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
#!/command/with-contenv bash
|
#!/command/with-contenv bash
|
||||||
# shellcheck shell=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
|
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
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
#!/command/with-contenv bash
|
#!/command/with-contenv bash
|
||||||
# shellcheck shell=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
|
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
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
oneshot
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/etc/s6-overlay/s6-rc.d/init-usermod/run
|
||||||
@@ -7,5 +7,12 @@ set -o errexit -o nounset -o pipefail
|
|||||||
dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx /dev/shm/logs/certsync)
|
dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx /dev/shm/logs/certsync)
|
||||||
|
|
||||||
mkdir -p "${dirs[@]}"
|
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[@]}"
|
chown nobody:nogroup "${dirs[@]}"
|
||||||
|
fi
|
||||||
chmod 02755 "${dirs[@]}"
|
chmod 02755 "${dirs[@]}"
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
#!/command/with-contenv bash
|
#!/command/with-contenv bash
|
||||||
# shellcheck shell=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
|
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
|
||||||
|
|||||||
@@ -77,15 +77,20 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.
|
|||||||
openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
|
openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
|
||||||
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \
|
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \
|
||||||
-keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null
|
-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
|
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
|
# build templates for optional FRIGATE_BASE_PATH environment variable
|
||||||
python3 /usr/local/nginx/get_nginx_settings.py | \
|
echo "$nginx_settings" | \
|
||||||
tempio -template /usr/local/nginx/templates/base_path.gotmpl \
|
tempio -template /usr/local/nginx/templates/base_path.gotmpl \
|
||||||
-out /usr/local/nginx/conf/base_path.conf
|
-out /usr/local/nginx/conf/base_path.conf
|
||||||
|
|
||||||
# build templates for additional network settings
|
# build templates for additional network settings
|
||||||
python3 /usr/local/nginx/get_nginx_settings.py | \
|
echo "$nginx_settings" | \
|
||||||
tempio -template /usr/local/nginx/templates/listen.gotmpl \
|
tempio -template /usr/local/nginx/templates/listen.gotmpl \
|
||||||
-out /usr/local/nginx/conf/listen.conf
|
-out /usr/local/nginx/conf/listen.conf
|
||||||
|
|
||||||
|
|||||||
@@ -144,3 +144,16 @@ rm -f /dev/shm/.frigate-is-stopping
|
|||||||
|
|
||||||
migrate_addon_config_dir
|
migrate_addon_config_dir
|
||||||
migrate_db_from_media_to_config
|
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
|
||||||
|
|||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
#!/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,13 +3,12 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
sys.path.insert(0, "/opt/frigate")
|
sys.path.insert(0, "/opt/frigate")
|
||||||
from frigate.config.env import substitute_frigate_vars
|
from frigate.config.env import apply_config_env_vars, substitute_frigate_vars
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
BIRDSEYE_PIPE,
|
BIRDSEYE_PIPE,
|
||||||
LIBAVFORMAT_VERSION_MAJOR,
|
LIBAVFORMAT_VERSION_MAJOR,
|
||||||
@@ -25,15 +24,6 @@ sys.path.remove("/opt/frigate")
|
|||||||
|
|
||||||
yaml = YAML()
|
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()
|
config_file = find_config_file()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -47,6 +37,20 @@ try:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
config: dict[str, Any] = {}
|
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", {})
|
go2rtc_config: dict[str, Any] = config.get("go2rtc", {})
|
||||||
|
|
||||||
# Need to enable CORS for go2rtc so the frigate integration / card work automatically
|
# Need to enable CORS for go2rtc so the frigate integration / card work automatically
|
||||||
@@ -113,7 +117,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
|||||||
|
|
||||||
if isinstance(stream, str):
|
if isinstance(stream, str):
|
||||||
try:
|
try:
|
||||||
formatted_stream = stream.format(**FRIGATE_ENV_VARS)
|
formatted_stream = substitute_frigate_vars(stream)
|
||||||
if is_restricted_go2rtc_source(formatted_stream):
|
if is_restricted_go2rtc_source(formatted_stream):
|
||||||
print(
|
print(
|
||||||
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. "
|
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. "
|
||||||
@@ -122,7 +126,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
|||||||
del go2rtc_config["streams"][name]
|
del go2rtc_config["streams"][name]
|
||||||
continue
|
continue
|
||||||
go2rtc_config["streams"][name] = formatted_stream
|
go2rtc_config["streams"][name] = formatted_stream
|
||||||
except KeyError as e:
|
except ValueError as e:
|
||||||
print(
|
print(
|
||||||
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
|
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
|
||||||
)
|
)
|
||||||
@@ -132,7 +136,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
|||||||
filtered_streams = []
|
filtered_streams = []
|
||||||
for i, stream_item in enumerate(stream):
|
for i, stream_item in enumerate(stream):
|
||||||
try:
|
try:
|
||||||
formatted_stream = stream_item.format(**FRIGATE_ENV_VARS)
|
formatted_stream = substitute_frigate_vars(stream_item)
|
||||||
if is_restricted_go2rtc_source(formatted_stream):
|
if is_restricted_go2rtc_source(formatted_stream):
|
||||||
print(
|
print(
|
||||||
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. "
|
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. "
|
||||||
@@ -141,7 +145,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
filtered_streams.append(formatted_stream)
|
filtered_streams.append(formatted_stream)
|
||||||
except KeyError as e:
|
except ValueError as e:
|
||||||
print(
|
print(
|
||||||
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
|
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
|
||||||
)
|
)
|
||||||
@@ -185,3 +189,6 @@ if config.get("birdseye", {}).get("restream", False):
|
|||||||
# Write go2rtc_config to /dev/shm/go2rtc.yaml
|
# Write go2rtc_config to /dev/shm/go2rtc.yaml
|
||||||
with open("/dev/shm/go2rtc.yaml", "w") as f:
|
with open("/dev/shm/go2rtc.yaml", "w") as f:
|
||||||
yaml.dump(go2rtc_config, f)
|
yaml.dump(go2rtc_config, f)
|
||||||
|
|
||||||
|
# config contains camera credentials; do not leave it world-readable
|
||||||
|
os.chmod("/dev/shm/go2rtc.yaml", 0o640)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ events {
|
|||||||
|
|
||||||
http {
|
http {
|
||||||
map_hash_bucket_size 256;
|
map_hash_bucket_size 256;
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
include mime.types;
|
include mime.types;
|
||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
@@ -62,6 +63,7 @@ http {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
include listen.conf;
|
include listen.conf;
|
||||||
|
include security_headers.conf;
|
||||||
|
|
||||||
# enable HTTP/2 for TLS connections to eliminate browser 6-connection limit
|
# enable HTTP/2 for TLS connections to eliminate browser 6-connection limit
|
||||||
http2 on;
|
http2 on;
|
||||||
@@ -123,6 +125,7 @@ http {
|
|||||||
secure_token $args;
|
secure_token $args;
|
||||||
secure_token_types application/vnd.apple.mpegurl;
|
secure_token_types application/vnd.apple.mpegurl;
|
||||||
|
|
||||||
|
include security_headers.conf;
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
expires off;
|
expires off;
|
||||||
|
|
||||||
@@ -139,6 +142,7 @@ http {
|
|||||||
|
|
||||||
location /stream/ {
|
location /stream/ {
|
||||||
include auth_request.conf;
|
include auth_request.conf;
|
||||||
|
include security_headers.conf;
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
expires off;
|
expires off;
|
||||||
|
|
||||||
@@ -160,6 +164,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expires 7d;
|
expires 7d;
|
||||||
|
include security_headers.conf;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
autoindex on;
|
autoindex on;
|
||||||
root /media/frigate;
|
root /media/frigate;
|
||||||
@@ -252,6 +257,7 @@ http {
|
|||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
include auth_request.conf;
|
include auth_request.conf;
|
||||||
|
include security_headers.conf;
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
expires off;
|
expires off;
|
||||||
proxy_pass http://frigate_api/;
|
proxy_pass http://frigate_api/;
|
||||||
@@ -318,29 +324,34 @@ http {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
# do not require auth for static assets
|
# do not require auth for static assets
|
||||||
|
include security_headers.conf;
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
expires off;
|
expires off;
|
||||||
|
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
access_log off;
|
access_log off;
|
||||||
expires 1y;
|
expires 1y;
|
||||||
|
include security_headers.conf;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
location /fonts/ {
|
location /fonts/ {
|
||||||
access_log off;
|
access_log off;
|
||||||
expires 1y;
|
expires 1y;
|
||||||
|
include security_headers.conf;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
location /locales/ {
|
location /locales/ {
|
||||||
access_log off;
|
access_log off;
|
||||||
|
include security_headers.conf;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ {
|
location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ {
|
||||||
access_log off;
|
access_log off;
|
||||||
expires 1y;
|
expires 1y;
|
||||||
|
include security_headers.conf;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
default_type application/json;
|
default_type application/json;
|
||||||
proxy_set_header Accept-Encoding "";
|
proxy_set_header Accept-Encoding "";
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 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;
|
||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/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"
|
||||||
@@ -4,8 +4,8 @@ edgeTPU:
|
|||||||
- key: mobiledet
|
- key: mobiledet
|
||||||
label: Mobiledet
|
label: Mobiledet
|
||||||
recommended: true
|
recommended: true
|
||||||
download: A TensorFlow Lite model is provided in the container at `/edgetpu_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`.
|
download: A TensorFlow Lite model is provided in the container at `/edgetpu_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 a model's `path`.
|
||||||
ui: Navigate to **Settings > System > Detectors and model** and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.
|
ui: Navigate to **Settings > System > Detection models** and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown.
|
||||||
yaml: |-
|
yaml: |-
|
||||||
models:
|
models:
|
||||||
- devices:
|
- devices:
|
||||||
@@ -13,9 +13,9 @@ edgeTPU:
|
|||||||
- key: yolov9
|
- key: yolov9
|
||||||
label: YOLOv9
|
label: YOLOv9
|
||||||
recommended: false
|
recommended: false
|
||||||
download: "[Download the model](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite), bind mount the file into the container, and provide the path with `model.path`. Note that the linked model requires a 17-label [labelmap file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) that includes only 17 COCO classes."
|
download: "[Download the model](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite), bind mount the file into the container, and provide the path with a model's `path`. Note that the linked model requires a 17-label [labelmap file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) that includes only 17 COCO classes."
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. Then on the same page, in the **Custom Model** tab, configure the model settings:
|
Navigate to **Settings > System > Detection models** and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure the model settings:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||||
@@ -44,7 +44,7 @@ hailo8l:
|
|||||||
recommended: true
|
recommended: true
|
||||||
download: If no custom model path or URL is provided, the Hailo detector automatically downloads the default model (YOLOv6n) from the Hailo Model Zoo on first startup based on the detected hardware. Once cached under `/config/model_cache/hailo`, the model works fully offline.
|
download: If no custom model path or URL is provided, the Hailo detector automatically downloads the default model (YOLOv6n) from the Hailo Model Zoo on first startup based on the detected hardware. Once cached under `/config/model_cache/hailo`, the model works fully offline.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings:
|
Navigate to **Settings > System > Detection models** and select **Hailo** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure the model settings:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ----------------------- |
|
| ---------------------------------------- | ----------------------- |
|
||||||
@@ -86,7 +86,7 @@ hailo8l:
|
|||||||
recommended: false
|
recommended: false
|
||||||
download: For SSD-based models, provide either a model path or URL to your compiled SSD model. The integration will first check the local path before downloading if necessary. The model file is cached under `/config/model_cache/hailo`.
|
download: For SSD-based models, provide either a model path or URL to your compiled SSD model. The integration will first check the local path before downloading if necessary. The model file is cached under `/config/model_cache/hailo`.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings:
|
Navigate to **Settings > System > Detection models** and select **Hailo** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure the model settings:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| --------------------------------------- | ------ |
|
| --------------------------------------- | ------ |
|
||||||
@@ -143,7 +143,7 @@ openvino:
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **Intel GPU** (or **Intel NPU**) from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | -------------------------------------------------------- |
|
| ---------------------------------------- | -------------------------------------------------------- |
|
||||||
@@ -171,7 +171,7 @@ openvino:
|
|||||||
recommended: false
|
recommended: false
|
||||||
download: An OpenVINO model is provided in the container at `/openvino-model/ssdlite_mobilenet_v2.xml` and is used by this detector type by default. The model comes from Intel's Open Model Zoo [SSDLite MobileNet V2](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/ssdlite_mobilenet_v2) and is converted to an FP16 precision IR model.
|
download: An OpenVINO model is provided in the container at `/openvino-model/ssdlite_mobilenet_v2.xml` and is used by this detector type by default. The model comes from Intel's Open Model Zoo [SSDLite MobileNet V2](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/ssdlite_mobilenet_v2) and is converted to an FP16 precision IR model.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **Intel GPU** (or **Intel NPU**) from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ------------------------------------------ |
|
| ---------------------------------------- | ------------------------------------------ |
|
||||||
@@ -206,7 +206,7 @@ openvino:
|
|||||||
python3 yolo_to_onnx.py -m yolov7-320
|
python3 yolo_to_onnx.py -m yolov7-320
|
||||||
```
|
```
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **Intel GPU** (or **Intel NPU**) from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | -------------------------------------------------------- |
|
| ---------------------------------------- | -------------------------------------------------------- |
|
||||||
@@ -243,7 +243,7 @@ openvino:
|
|||||||
|
|
||||||
The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired.
|
The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **Intel GPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ------------------------------------------------- |
|
| ---------------------------------------- | ------------------------------------------------- |
|
||||||
@@ -271,7 +271,7 @@ openvino:
|
|||||||
recommended: false
|
recommended: false
|
||||||
download: YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime).
|
download: YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime).
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **Intel GPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ------------------------------------- | -------------------------------- |
|
| ------------------------------------- | -------------------------------- |
|
||||||
@@ -308,7 +308,7 @@ openvino:
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **Intel GPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| --------------------------------------- | --------------------------------- |
|
| --------------------------------------- | --------------------------------- |
|
||||||
@@ -402,7 +402,7 @@ openvino:
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `CPU`. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **CPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ---------------------------------- |
|
| ---------------------------------------- | ---------------------------------- |
|
||||||
@@ -455,7 +455,7 @@ appleSilicon:
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and add a model. The ZMQ endpoint is not reported by the hardware probe, so set `devices` to `zmq:tcp://host.docker.internal:5555` in YAML. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | -------------------------------------------------------- |
|
| ---------------------------------------- | -------------------------------------------------------- |
|
||||||
@@ -491,7 +491,7 @@ appleSilicon:
|
|||||||
python3 yolo_to_onnx.py -m yolov7-320
|
python3 yolo_to_onnx.py -m yolov7-320
|
||||||
```
|
```
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and add a model. The ZMQ endpoint is not reported by the hardware probe, so set `devices` to `zmq:tcp://host.docker.internal:5555` in YAML. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | -------------------------------------------------------- |
|
| ---------------------------------------- | -------------------------------------------------------- |
|
||||||
@@ -544,7 +544,7 @@ onnx:
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | -------------------------------------------------------- |
|
| ---------------------------------------- | -------------------------------------------------------- |
|
||||||
@@ -588,7 +588,7 @@ onnx:
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| --------------------------------------- | --------------------------------- |
|
| --------------------------------------- | --------------------------------- |
|
||||||
@@ -623,7 +623,7 @@ onnx:
|
|||||||
|
|
||||||
The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired.
|
The input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ------------------------------------------------- |
|
| ---------------------------------------- | ------------------------------------------------- |
|
||||||
@@ -651,7 +651,7 @@ onnx:
|
|||||||
recommended: false
|
recommended: false
|
||||||
download: YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime).
|
download: YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime).
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | -------------------------------------------------------- |
|
| ---------------------------------------- | -------------------------------------------------------- |
|
||||||
@@ -747,7 +747,7 @@ onnx:
|
|||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ------------------------------------------- |
|
| ---------------------------------------- | ------------------------------------------- |
|
||||||
@@ -783,7 +783,7 @@ onnx:
|
|||||||
python3 yolo_to_onnx.py -m yolov7-320
|
python3 yolo_to_onnx.py -m yolov7-320
|
||||||
```
|
```
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select your GPU from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | -------------------------------------------------------- |
|
| ---------------------------------------- | -------------------------------------------------------- |
|
||||||
@@ -812,9 +812,9 @@ cpu:
|
|||||||
- key: ssd
|
- key: ssd
|
||||||
label: MobileNet v2
|
label: MobileNet v2
|
||||||
recommended: true
|
recommended: true
|
||||||
download: 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`.
|
download: 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 a model's `path`.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **CPU** from the detector type dropdown and click **Add**. Configure the number of threads and click **Add** again to add additional CPU detectors as needed (one per camera is recommended).
|
Navigate to **Settings > System > Detection models** and select **CPU** from the **Hardware** dropdown and set **Detectors** to the number of detection processes to run (one per camera is recommended).
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ----------------- | ----- |
|
| ----------------- | ----- |
|
||||||
@@ -832,7 +832,7 @@ deepstack:
|
|||||||
recommended: true
|
recommended: true
|
||||||
download: This detector runs object detection over the network against a CodeProject.AI or DeepStack server, so no model is downloaded into Frigate itself. Visit the [CodeProject.AI official website](https://www.codeproject.com/Articles/5322557/CodeProject-AI-Server-AI-the-easy-way) to download and install the AI server on your preferred device (e.g. Raspberry Pi, Nvidia Jetson, or other compatible hardware) before configuring the detector.
|
download: This detector runs object detection over the network against a CodeProject.AI or DeepStack server, so no model is downloaded into Frigate itself. Visit the [CodeProject.AI official website](https://www.codeproject.com/Articles/5322557/CodeProject-AI-Server-AI-the-easy-way) to download and install the AI server on your preferred device (e.g. Raspberry Pi, Nvidia Jetson, or other compatible hardware) before configuring the detector.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **DeepStack** from the detector type dropdown and click **Add**. Set the API URL to point to your CodeProject.AI server (e.g., `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection`).
|
Navigate to **Settings > System > Detection models** and add a model. The CodeProject.AI server is not reported by the hardware probe, so set `devices` to `deepstack:http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection` in YAML.
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ------------- | ---------------------------------------------------------------------- |
|
| ------------- | ---------------------------------------------------------------------- |
|
||||||
@@ -859,7 +859,7 @@ memryx:
|
|||||||
|
|
||||||
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
|
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **MemryX MX3** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ------------------------------------------------- |
|
| ---------------------------------------- | ------------------------------------------------- |
|
||||||
@@ -893,7 +893,7 @@ memryx:
|
|||||||
|
|
||||||
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
|
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **MemryX MX3** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ------------------------------------------------- |
|
| ---------------------------------------- | ------------------------------------------------- |
|
||||||
@@ -926,7 +926,7 @@ memryx:
|
|||||||
|
|
||||||
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
|
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **MemryX MX3** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ----------------------- |
|
| ---------------------------------------- | ----------------------- |
|
||||||
@@ -959,7 +959,7 @@ memryx:
|
|||||||
|
|
||||||
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
|
MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **MemryX MX3** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ----------------------- |
|
| ---------------------------------------- | ----------------------- |
|
||||||
@@ -1005,7 +1005,7 @@ tensorrt:
|
|||||||
- USE_FP16=false
|
- USE_FP16=false
|
||||||
```
|
```
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **TensorRT** from the detector type dropdown and click **Add**, then set the device to `0` (the default GPU index). Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **NVIDIA Jetson** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ------------------------------------------------------------ |
|
| ---------------------------------------- | ------------------------------------------------------------ |
|
||||||
@@ -1035,7 +1035,7 @@ synaptics:
|
|||||||
recommended: true
|
recommended: true
|
||||||
download: A synap model is provided in the container at `/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).
|
download: A synap model is provided in the container at `/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **Synaptics** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **Synaptics NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ---------------------------- |
|
| ---------------------------------------- | ---------------------------- |
|
||||||
@@ -1068,7 +1068,7 @@ rknn:
|
|||||||
|
|
||||||
You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.
|
You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** select **Rockchip NPU** from the **Hardware** dropdown, then open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | -------------------------------------------------- |
|
| ---------------------------------------- | -------------------------------------------------- |
|
||||||
@@ -1108,7 +1108,7 @@ rknn:
|
|||||||
|
|
||||||
**Note:** The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html
|
**Note:** The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** select **Rockchip NPU** from the **Hardware** dropdown, then open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ----------------------------------------------------------------------- |
|
| ---------------------------------------- | ----------------------------------------------------------------------- |
|
||||||
@@ -1145,7 +1145,7 @@ rknn:
|
|||||||
|
|
||||||
You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.
|
You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** select **Rockchip NPU** from the **Hardware** dropdown, then open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ---------------------------------------------- |
|
| ---------------------------------------- | ---------------------------------------------- |
|
||||||
@@ -1182,7 +1182,7 @@ axengine:
|
|||||||
recommended: true
|
recommended: true
|
||||||
download: A yolov9 axmodel is provided in the container at `/axmodels` and is used by this detector type by default. The AXEngine detector downloads its default model from HuggingFace on first startup; once cached, the model works fully offline.
|
download: A yolov9 axmodel is provided in the container at `/axmodels` and is used by this detector type by default. The AXEngine detector downloads its default model from HuggingFace on first startup; once cached, the model works fully offline.
|
||||||
ui: |-
|
ui: |-
|
||||||
Navigate to **Settings > System > Detectors and model** and select **AXEngine NPU** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
Navigate to **Settings > System > Detection models** and select **AXERA NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ----------------------- |
|
| ---------------------------------------- | ----------------------- |
|
||||||
@@ -1195,20 +1195,6 @@ axengine:
|
|||||||
| **Model Input D Type** | `int` |
|
| **Model Input D Type** | `int` |
|
||||||
| **Object Detection Model Type** | `yolo-generic` |
|
| **Object Detection Model Type** | `yolo-generic` |
|
||||||
yaml: |-
|
yaml: |-
|
||||||
<<<<<<< HEAD
|
|
||||||
detectors:
|
|
||||||
axengine:
|
|
||||||
type: axengine
|
|
||||||
|
|
||||||
model:
|
|
||||||
path: frigate-yolov9-tiny
|
|
||||||
model_type: yolo-generic
|
|
||||||
width: 320
|
|
||||||
height: 320
|
|
||||||
input_dtype: int
|
|
||||||
input_pixel_format: bgr
|
|
||||||
labelmap_path: /labelmap/coco-80.txt
|
|
||||||
=======
|
|
||||||
models:
|
models:
|
||||||
- devices:
|
- devices:
|
||||||
- axengine
|
- axengine
|
||||||
@@ -1219,85 +1205,3 @@ axengine:
|
|||||||
input_dtype: int
|
input_dtype: int
|
||||||
input_pixel_format: bgr
|
input_pixel_format: bgr
|
||||||
labelmap_path: /labelmap/coco-80.txt
|
labelmap_path: /labelmap/coco-80.txt
|
||||||
degirumAiServer:
|
|
||||||
title: DeGirum AI Server
|
|
||||||
models:
|
|
||||||
- key: ai-server-inference
|
|
||||||
label: AI Server Inference
|
|
||||||
recommended: true
|
|
||||||
download: |-
|
|
||||||
Launch a DeGirum AI server as a Docker container, then point the detector at it. Add this to your `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
degirum_detector:
|
|
||||||
container_name: degirum
|
|
||||||
image: degirum/aiserver:latest
|
|
||||||
privileged: true
|
|
||||||
ports:
|
|
||||||
- "8778:8778"
|
|
||||||
```
|
|
||||||
|
|
||||||
Set `location` to the server's service name, container name, or `host:port`.
|
|
||||||
ui: |
|
|
||||||
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| **Location** | `degirum` |
|
|
||||||
| **Zoo** | `degirum/public` |
|
|
||||||
| **Token** | your AI Hub token (optional for the public zoo) |
|
|
||||||
yaml: |
|
|
||||||
models:
|
|
||||||
- devices:
|
|
||||||
- degirum:degirum
|
|
||||||
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
|
|
||||||
width: 300
|
|
||||||
height: 300
|
|
||||||
input_pixel_format: rgb
|
|
||||||
degirumLocal:
|
|
||||||
title: DeGirum Local
|
|
||||||
models:
|
|
||||||
- key: local-inference
|
|
||||||
label: Local Inference
|
|
||||||
recommended: true
|
|
||||||
download: Run hardware directly inside the Frigate container with `@local`, removing the AI server hop. The matching device runtime (e.g. the Hailo runtime) must be installed in the container; confirm it with `degirum sys-info`.
|
|
||||||
ui: |
|
|
||||||
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| **Location** | `@local` |
|
|
||||||
| **Zoo** | `degirum/public` |
|
|
||||||
| **Token** | your AI Hub token (optional for the public zoo) |
|
|
||||||
yaml: |
|
|
||||||
models:
|
|
||||||
- devices:
|
|
||||||
- degirum:@local
|
|
||||||
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
|
|
||||||
width: 300
|
|
||||||
height: 300
|
|
||||||
input_pixel_format: rgb
|
|
||||||
degirumCloud:
|
|
||||||
title: DeGirum AI Hub Cloud
|
|
||||||
models:
|
|
||||||
- key: ai-hub-cloud-inference
|
|
||||||
label: AI Hub Cloud Inference
|
|
||||||
recommended: true
|
|
||||||
download: Run inferences on DeGirum's [AI Hub](https://hub.degirum.com) cloud with `@cloud`. Sign up, create an access token, and set it as `token`. Network latency may require lowering your detection fps.
|
|
||||||
ui: |
|
|
||||||
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| **Location** | `@cloud` |
|
|
||||||
| **Zoo** | `degirum/public` |
|
|
||||||
| **Token** | your AI Hub token (optional for the public zoo) |
|
|
||||||
yaml: |
|
|
||||||
models:
|
|
||||||
- devices:
|
|
||||||
- degirum:@cloud
|
|
||||||
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
|
|
||||||
width: 300
|
|
||||||
height: 300
|
|
||||||
input_pixel_format: rgb
|
|
||||||
>>>>>>> 34363affa (Refactor detector and model management)
|
|
||||||
|
|||||||
@@ -218,6 +218,8 @@ audio:
|
|||||||
- fire_alarm
|
- fire_alarm
|
||||||
- speech
|
- speech
|
||||||
- yell
|
- yell
|
||||||
|
# Optional: Audio label name modifications. These are merged into the standard audio labelmap.
|
||||||
|
labelmap: {}
|
||||||
# Optional: Filters to configure detection.
|
# Optional: Filters to configure detection.
|
||||||
filters:
|
filters:
|
||||||
# Label that matches label in listen config.
|
# Label that matches label in listen config.
|
||||||
|
|||||||
@@ -63,15 +63,9 @@ go2rtc:
|
|||||||
|
|
||||||
### `environment_vars`
|
### `environment_vars`
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
:::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>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
@@ -80,23 +74,17 @@ Navigate to <NavPath path="Settings > System > Environment variables" /> to add
|
|||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
| ----------------- | --------------------------------------------------------- |
|
| ----------------- | --------------------------------------------------------- |
|
||||||
| **Variable name** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) |
|
| **Variable name** | The environment variable name (e.g., `LIBVA_DRIVER_NAME`) |
|
||||||
| **Value** | The value for the variable |
|
| **Value** | The value for the variable |
|
||||||
|
|
||||||
Variables defined here can be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax.
|
Names prefixed with `FRIGATE_` can also be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment_vars:
|
environment_vars:
|
||||||
FRIGATE_MQTT_USER: my_mqtt_user
|
LIBVA_DRIVER_NAME: i965
|
||||||
FRIGATE_MQTT_PASSWORD: my_mqtt_password
|
|
||||||
|
|
||||||
mqtt:
|
|
||||||
host: "{FRIGATE_MQTT_HOST}"
|
|
||||||
user: "{FRIGATE_MQTT_USER}"
|
|
||||||
password: "{FRIGATE_MQTT_PASSWORD}"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
@@ -130,6 +118,51 @@ environment_vars:
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
</ConfigTabs>
|
</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`
|
### `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.
|
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.
|
||||||
@@ -177,7 +210,7 @@ Custom models may also require different input tensor formats. The colorspace co
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|
||||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and open the **Custom Model** tab to configure the model path, dimensions, and input format.
|
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.
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
| --------------------------------------------- | ------------------------------------ |
|
| --------------------------------------------- | ------------------------------------ |
|
||||||
|
|||||||
@@ -114,6 +114,30 @@ audio:
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
</ConfigTabs>
|
</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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -84,9 +84,10 @@ 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:
|
Each role can only be assigned to one input per camera. The options for roles are as follows:
|
||||||
|
|
||||||
| Role | Description |
|
| Role | Description |
|
||||||
| -------- | ----------------------------------------------------------------------------------- |
|
| ------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||||
| `detect` | Main feed for object detection. [docs](object_detectors.md) |
|
| `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` | 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) |
|
| `audio` | Feed for audio based detection. [docs](audio_detectors.md) |
|
||||||
|
|
||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ VS Code supports JSON schemas for automatically validating configuration files.
|
|||||||
|
|
||||||
## Environment Variable Substitution
|
## Environment Variable Substitution
|
||||||
|
|
||||||
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:
|
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:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
mqtt:
|
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
|
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)`
|
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 > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
|
3. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown
|
||||||
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`
|
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`
|
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
|
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
|
||||||
@@ -232,7 +232,7 @@ cameras:
|
|||||||
|
|
||||||
1. Navigate to <NavPath path="Settings > System > MQTT" /> and set **Enable MQTT** to off
|
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)`
|
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 > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
|
3. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown
|
||||||
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`
|
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`
|
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
|
6. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
|
||||||
@@ -308,8 +308,8 @@ cameras:
|
|||||||
|
|
||||||
1. Navigate to <NavPath path="Settings > System > MQTT" /> and configure the connection to your MQTT broker
|
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)`
|
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 > Detectors and model" /> and add a detector with **Type** `openvino` and **Device** `AUTO`
|
3. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Intel GPU** from the **Hardware** dropdown
|
||||||
4. On the same page, in the **Custom Model** tab, configure the OpenVINO model path and settings
|
4. On the same model, open the **Custom Model** tab and 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`
|
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`
|
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
|
7. Navigate to <NavPath path="Settings > Global configuration > Camera management" /> and add your camera with the appropriate RTSP stream URL
|
||||||
|
|||||||
@@ -59,14 +59,18 @@ Running Generative AI models on CPU is not recommended, as high inference times
|
|||||||
|
|
||||||
### Recommended Local Models
|
### Recommended Local Models
|
||||||
|
|
||||||
|
#### Vision models
|
||||||
|
|
||||||
You must use a vision-capable model with Frigate. The following models are recommended for local deployment of the `descriptions` and `chat` roles:
|
You must use a vision-capable model with Frigate. The following models are recommended for local deployment of the `descriptions` and `chat` roles:
|
||||||
|
|
||||||
| Model | Notes |
|
| Model | Notes |
|
||||||
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. |
|
| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. |
|
||||||
| `qwen3.6` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
|
| `qwen3.6`/`qwen3.8` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
|
||||||
| `gemma4` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. |
|
| `gemma4` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. |
|
||||||
|
|
||||||
|
#### Embedding models
|
||||||
|
|
||||||
The `embeddings` role needs a different kind of model. Text queries are matched against the stored image embeddings, so the model must be trained to place images and text into the same vector space. A chat or description model will still return vectors when asked, but those vectors are not trained for retrieval and text searches will return poor matches with no error to indicate why.
|
The `embeddings` role needs a different kind of model. Text queries are matched against the stored image embeddings, so the model must be trained to place images and text into the same vector space. A chat or description model will still return vectors when asked, but those vectors are not trained for retrieval and text searches will return poor matches with no error to indicate why.
|
||||||
|
|
||||||
| Model | Notes |
|
| Model | Notes |
|
||||||
|
|||||||
@@ -312,8 +312,9 @@ ffmpeg:
|
|||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
If running Frigate through Docker, you either need to run in privileged mode or
|
If running Frigate through Docker, map the relevant `/dev/video*` devices into
|
||||||
map the `/dev/video*` devices to Frigate. With Docker Compose add:
|
the container. Running in privileged mode also works but grants far more access
|
||||||
|
than needed. With Docker Compose add:
|
||||||
|
|
||||||
```yaml {4-5}
|
```yaml {4-5}
|
||||||
services:
|
services:
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ See [common Edge TPU troubleshooting steps](/troubleshooting/edgetpu) if the Edg
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|
||||||
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`.
|
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
@@ -184,7 +184,7 @@ models:
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
@@ -206,7 +206,7 @@ _warning: may have [compatibility issues](https://github.com/blakeblackshear/fri
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|
||||||
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.
|
Navigate to <NavPath path="Settings > System > Detection models" /> and select the **Coral EdgeTPU** entry from the **Hardware** dropdown.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
@@ -225,7 +225,7 @@ models:
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|
||||||
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`.
|
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (PCIe)** from the **Hardware** dropdown.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
@@ -244,7 +244,7 @@ models:
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
@@ -264,7 +264,7 @@ models:
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|
||||||
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`).
|
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.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
@@ -354,7 +354,7 @@ Intel NPUs cannot be used under Home Assistant OS, which does not include the NP
|
|||||||
|
|
||||||
:::warning
|
:::warning
|
||||||
|
|
||||||
The network-based detectors (Deepstack, DeGirum, 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`, DeGirum ignores `zoo` and `token`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
|
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.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
@@ -536,7 +536,7 @@ When using CPU detectors, you can add one CPU detector per camera. Adding more d
|
|||||||
|
|
||||||
:::warning
|
:::warning
|
||||||
|
|
||||||
The network-based detectors (Deepstack, DeGirum, 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`, DeGirum ignores `zoo` and `token`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
|
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.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
@@ -809,101 +809,6 @@ Explanation of the parameters:
|
|||||||
- **example**: Specifying `output_name = "frigate-{quant}-{input_basename}-{soc}-v{tk_version}"` could result in a model called `frigate-i8-my_model-rk3588-v2.3.0.rknn`.
|
- **example**: Specifying `output_name = "frigate-{quant}-{input_basename}-{soc}-v{tk_version}"` could result in a model called `frigate-i8-my_model-rk3588-v2.3.0.rknn`.
|
||||||
- `config`: Configuration passed to `rknn-toolkit2` for model conversion. For an explanation of all available parameters have a look at section "2.2. Model configuration" of [this manual](https://github.com/MarcA711/rknn-toolkit2/releases/download/v2.3.2/03_Rockchip_RKNPU_API_Reference_RKNN_Toolkit2_V2.3.2_EN.pdf).
|
- `config`: Configuration passed to `rknn-toolkit2` for model conversion. For an explanation of all available parameters have a look at section "2.2. Model configuration" of [this manual](https://github.com/MarcA711/rknn-toolkit2/releases/download/v2.3.2/03_Rockchip_RKNPU_API_Reference_RKNN_Toolkit2_V2.3.2_EN.pdf).
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
=======
|
|
||||||
## DeGirum
|
|
||||||
|
|
||||||
:::warning
|
|
||||||
|
|
||||||
The network-based detectors (Deepstack, DeGirum, 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`, DeGirum ignores `zoo` and `token`, and the Apple Silicon client ignores `request_timeout_ms` and `linger_ms`. Anything else is dropped when your config is migrated.
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
DeGirum is a detector that can use any type of hardware listed on [their website](https://hub.degirum.com). DeGirum can be used with local hardware through a DeGirum AI Server, or through the use of `@local`. You can also connect directly to DeGirum's AI Hub to run inferences. **Please Note:** This detector _cannot_ be used for commercial purposes.
|
|
||||||
|
|
||||||
### Configuration {#configuration-degirum}
|
|
||||||
|
|
||||||
#### AI Server Inference
|
|
||||||
|
|
||||||
Before starting with the config file for this section, you must first launch an AI server. DeGirum has an AI server ready to use as a docker container. Add this to your `docker-compose.yml` to get started:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
degirum_detector:
|
|
||||||
container_name: degirum
|
|
||||||
image: degirum/aiserver:latest
|
|
||||||
privileged: true
|
|
||||||
ports:
|
|
||||||
- "8778:8778"
|
|
||||||
```
|
|
||||||
|
|
||||||
All supported hardware will automatically be found on your AI server host as long as relevant runtimes and drivers are properly installed on your machine. Refer to [DeGirum's docs site](https://docs.degirum.com/pysdk/runtimes-and-drivers) if you have any trouble.
|
|
||||||
|
|
||||||
Once completed, configure the detector as follows:
|
|
||||||
|
|
||||||
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumAiServer.models} />
|
|
||||||
|
|
||||||
The model is set on the same `models` entry as the DeGirum device. You can set it to:
|
|
||||||
|
|
||||||
- A model listed on the [AI Hub](https://hub.degirum.com)
|
|
||||||
- If this is what you choose to do, the correct model will be downloaded onto your machine before running.
|
|
||||||
- A local directory acting as a zoo. See DeGirum's docs site [for more information](https://docs.degirum.com/pysdk/user-guide-pysdk/organizing-models#model-zoo-directory-structure).
|
|
||||||
- A path to some model.json.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
models:
|
|
||||||
- devices:
|
|
||||||
- degirum:<location>
|
|
||||||
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1 # directory to model .json and file
|
|
||||||
width: 300 # width is in the model name as the first number in the "int"x"int" section
|
|
||||||
height: 300 # height is in the model name as the second number in the "int"x"int" section
|
|
||||||
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Local Inference
|
|
||||||
|
|
||||||
It is also possible to eliminate the need for an AI server and run the hardware directly. The benefit of this approach is that you eliminate any bottlenecks that occur when transferring prediction results from the AI server docker container to the frigate one. However, the method of implementing local inference is different for every device and hardware combination, so it's usually more trouble than it's worth. A general guideline to achieve this would be:
|
|
||||||
|
|
||||||
1. Ensuring that the frigate docker container has the runtime you want to use. So for instance, running `@local` for Hailo means making sure the container you're using has the Hailo runtime installed.
|
|
||||||
2. To double check the runtime is detected by the DeGirum detector, make sure the `degirum sys-info` command properly shows whatever runtimes you mean to install.
|
|
||||||
3. Create a DeGirum detector in your configuration.
|
|
||||||
|
|
||||||
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumLocal.models} />
|
|
||||||
|
|
||||||
Once the DeGirum device is set up, you can choose a model on the same `models` entry in the `config.yml` file.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
models:
|
|
||||||
- devices:
|
|
||||||
- degirum:<location>
|
|
||||||
path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
|
|
||||||
width: 300 # width is in the model name as the first number in the "int"x"int" section
|
|
||||||
height: 300 # height is in the model name as the second number in the "int"x"int" section
|
|
||||||
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
|
||||||
```
|
|
||||||
|
|
||||||
#### AI Hub Cloud Inference
|
|
||||||
|
|
||||||
If you do not possess whatever hardware you want to run, there's also the option to run cloud inferences. Do note that your detection fps might need to be lowered as network latency does significantly slow down this method of detection. For use with Frigate, we highly recommend using a local AI server as described above. To set up cloud inferences,
|
|
||||||
|
|
||||||
1. Sign up at [DeGirum's AI Hub](https://hub.degirum.com).
|
|
||||||
2. Get an access token.
|
|
||||||
3. Create a DeGirum detector in your configuration.
|
|
||||||
|
|
||||||
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumCloud.models} />
|
|
||||||
|
|
||||||
Once the DeGirum device is set up, you can choose a model on the same `models` entry in the `config.yml` file.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
models:
|
|
||||||
- devices:
|
|
||||||
- degirum:<location>
|
|
||||||
path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
|
|
||||||
width: 300 # width is in the model name as the first number in the "int"x"int" section
|
|
||||||
height: 300 # height is in the model name as the second number in the "int"x"int" section
|
|
||||||
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
|
||||||
```
|
|
||||||
|
|
||||||
>>>>>>> 34363affa (Refactor detector and model management)
|
|
||||||
## AXERA
|
## AXERA
|
||||||
|
|
||||||
Hardware accelerated object detection is supported on the following SoCs:
|
Hardware accelerated object detection is supported on the following SoCs:
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ This configuration will retain recording segments that overlap with alerts and d
|
|||||||
In addition to the main recording stream, Frigate can record a second, lower quality stream for each camera. This serves two purposes:
|
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.
|
- **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.
|
- **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
|
### Configuring sub stream recording
|
||||||
|
|
||||||
@@ -421,13 +421,13 @@ As a general rule, features that read recordings prefer the main stream and fall
|
|||||||
| Audio extraction (e.g., transcription) | Main preferred, sub fallback |
|
| Audio extraction (e.g., transcription) | Main preferred, sub fallback |
|
||||||
| Motion search | Main only |
|
| Motion search | Main only |
|
||||||
| Review timeline motion data | Main only |
|
| Review timeline motion data | Main only |
|
||||||
| Storage usage statistics | Both streams counted |
|
| 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.
|
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
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
- **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.
|
||||||
|
|||||||
@@ -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.
|
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:
|
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:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ Generate a Frigate Docker Compose configuration based on your hardware and requi
|
|||||||
services:
|
services:
|
||||||
frigate:
|
frigate:
|
||||||
container_name: frigate
|
container_name: frigate
|
||||||
privileged: true # this may not be necessary for all setups
|
# privileged: true # ONLY enable if your hardware requires it (see hardware-specific docs); prefer the device mappings below
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 30s # allow enough time to shut down the various services
|
stop_grace_period: 30s # allow enough time to shut down the various services
|
||||||
image: ghcr.io/blakeblackshear/frigate:stable
|
image: ghcr.io/blakeblackshear/frigate:stable
|
||||||
@@ -546,6 +546,33 @@ services:
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
</Tabs>
|
</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**
|
**Docker CLI**
|
||||||
|
|
||||||
If you can't use Docker Compose, you can run the container with something similar to this:
|
If you can't use Docker Compose, you can run the container with something similar to this:
|
||||||
@@ -612,6 +639,8 @@ Home Assistant OS users can install via the App repository.
|
|||||||
5. Start the App
|
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
|
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:
|
There are several variants of the App available:
|
||||||
|
|
||||||
| App Variant | Description |
|
| App Variant | Description |
|
||||||
|
|||||||
@@ -204,8 +204,8 @@ You need to refer to **Configure hardware acceleration** above to enable the con
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|
||||||
1. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `OpenVINO` and **Device** `GPU`
|
1. Navigate to <NavPath path="Settings > System > Detection models" /> and select **Intel GPU** from the **Hardware** dropdown
|
||||||
2. On the same page, in the **Custom Model** tab, configure the model settings for OpenVINO:
|
2. On the same model, open the **Custom Model** tab and configure the model settings for OpenVINO:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ---------------------------------------- | ------------------------------------------ |
|
| ---------------------------------------- | ------------------------------------------ |
|
||||||
@@ -270,7 +270,7 @@ services:
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|
||||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`.
|
Navigate to <NavPath path="Settings > System > Detection models" /> and select **Coral EdgeTPU (USB)** from the **Hardware** dropdown.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ Topic with current state of notifications. Published values are `ON` and `OFF`.
|
|||||||
|
|
||||||
### `frigate/<camera_name>/status/<role>`
|
### `frigate/<camera_name>/status/<role>`
|
||||||
|
|
||||||
Publishes the current health status of each role that is enabled (`audio`, `detect`, `record`). Possible values are:
|
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:
|
||||||
|
|
||||||
- `online`: Stream is running and being processed
|
- `online`: Stream is running and being processed
|
||||||
- `offline`: Stream is offline and is being restarted
|
- `offline`: Stream is offline and is being restarted
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ 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.
|
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 > Detectors and model" /> pane in the Frigate UI (the **Frigate+ Model** tab), or manually set the model at the root level in your config:
|
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:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
models:
|
models:
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Models available in Frigate+ can be used with a special model path. No other inf
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
|
|||||||
@@ -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">
|
<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 `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).
|
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).
|
||||||
|
|
||||||
</FaqItem>
|
</FaqItem>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,31 @@ id: cpu
|
|||||||
title: High CPU Usage
|
title: High CPU Usage
|
||||||
---
|
---
|
||||||
|
|
||||||
High CPU usage can impact Frigate's performance and responsiveness. This guide outlines the most effective configuration changes to help reduce CPU consumption and optimize resource usage.
|
High CPU usage can impact Frigate's performance and responsiveness. This guide explains how to interpret the CPU values Frigate reports and outlines the most effective configuration changes to help reduce CPU consumption and optimize resource usage.
|
||||||
|
|
||||||
|
## Understanding Frigate's Reported CPU Usage
|
||||||
|
|
||||||
|
Frigate's CPU percentages often look much higher than what the host reports. Usually both numbers are correct and are simply measured against different denominators, so confirm you actually have a problem before tuning anything.
|
||||||
|
|
||||||
|
### Per-process values are relative to a single core
|
||||||
|
|
||||||
|
The values Frigate reports for FFmpeg, capture, detect, detector, and other processes follow the same convention as `top`: 100% means one CPU core is fully saturated, not that the whole system is saturated. A multithreaded process such as FFmpeg can legitimately report well over 100%.
|
||||||
|
|
||||||
|
Host and hypervisor tools instead report a percentage of the machine's total capacity across all cores. This includes `docker stats`, the `htop` summary, the Proxmox summary graph, the Unraid dashboard, Synology Resource Monitor, and Home Assistant's system monitor sensors. To reconcile the two:
|
||||||
|
|
||||||
|
```
|
||||||
|
host percentage ≈ (sum of Frigate's process percentages) / (number of cores)
|
||||||
|
```
|
||||||
|
|
||||||
|
On a 4 core system, an FFmpeg process reporting 100% is consuming one quarter of the machine, so the host will show roughly 25 to 30% once the remaining Frigate processes are included. That same 100% on a 16 core system is about 6%. Frigate's own warning thresholds use the per-core convention as well, so an FFmpeg process is flagged at 20% of a single core, not 20% of the system.
|
||||||
|
|
||||||
|
### Instantaneous samples and averages measure different things
|
||||||
|
|
||||||
|
Frigate collects stats every 15 seconds, and the `cpu` value covers only the interval since the previous collection. The `cpu_average` value in the stats API and MQTT payload is the average across the entire life of the process, and it is what the high CPU usage warnings are based on. Host dashboards generally plot data averaged over a longer window, so a single Frigate sample can show a peak that a host graph never displays. A process that has just started, such as FFmpeg after a camera reconnect, reports 0 until it has been sampled twice.
|
||||||
|
|
||||||
|
### The system-wide value depends on what the container can see
|
||||||
|
|
||||||
|
The system CPU value is read from `/proc/stat`. Under Docker that file belongs to the host, so the value covers the entire machine including workloads unrelated to Frigate, and it will not match `docker stats` for the Frigate container. Under an LXC container, lxcfs virtualizes `/proc/stat` and the value reflects only the cores assigned to the container. In a virtual machine, the guest sees only its assigned vCPUs while the hypervisor divides by every physical thread on the node, so guest and host percentages will not agree even when both are accurate.
|
||||||
|
|
||||||
## 1. Hardware Acceleration for Video Decoding
|
## 1. Hardware Acceleration for Video Decoding
|
||||||
|
|
||||||
@@ -72,3 +96,19 @@ The model you use significantly impacts detector performance. Frigate provides d
|
|||||||
- Larger models (640x640): Slower inference, can sometimes have higher accuracy on very large objects that take up a majority of the frame.
|
- Larger models (640x640): Slower inference, can sometimes have higher accuracy on very large objects that take up a majority of the frame.
|
||||||
|
|
||||||
For more detail on picking the right size, see [Choosing a model size](../configuration/object_detectors.md#choosing-a-model-size).
|
For more detail on picking the right size, see [Choosing a model size](../configuration/object_detectors.md#choosing-a-model-size).
|
||||||
|
|
||||||
|
## 3. Reducing Detector CPU Usage
|
||||||
|
|
||||||
|
**Priority: High**
|
||||||
|
|
||||||
|
The **Detector CPU Usage** metric measures the CPU spent converting frames into the tensor format the model expects and post-processing the model's output. It does not include inference, so this value can be high even when you've configured a GPU, NPU, or Coral for object detection.
|
||||||
|
|
||||||
|
This metric scales with how many detections per second Frigate runs and how expensive each one is to prepare. Tuning [motion detection](../configuration/motion_detection) is usually the first recommendation to reduce the number of detections. Additionally, you can:
|
||||||
|
|
||||||
|
- **Lower `detect -> fps`.** 5 is the recommended value for nearly all cameras. Running at 10 doubles the frames eligible for detection and is one of the largest contributors to this metric.
|
||||||
|
- **Use a 320x320 model.** A 640x640 model has 4 times as many pixels to transpose, convert, and copy on every inference.
|
||||||
|
- **Prefer a model that takes integer input.** Models configured with `input_dtype: float` require each frame to be converted to float32 and normalized on the CPU first. Models taking `int` input, such as the tflite models used by the Edge TPU, skip that step.
|
||||||
|
- **Do not match the detect resolution to the model resolution.** The detect stream should match your camera's aspect ratio, for example `1280x720`, not the model's input size. Frigate crops and scales regions of motion itself, so an oversized detect stream only adds work.
|
||||||
|
- **Tune stationary object behavior.** Objects that never settle into a stationary state are re-detected continuously. Raising `detect -> stationary -> interval` reduces how often detection runs on objects that are already parked. See [stationary objects](../configuration/stationary_objects).
|
||||||
|
|
||||||
|
Adding [more detector instances](#multiple-detector-instances) spreads this work across more CPU cores, but does not reduce the total CPU used.
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ cameras:
|
|||||||
height: 720
|
height: 720
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### What is the `version` key in my config file?
|
||||||
|
|
||||||
|
`version` records the config format that your config was last migrated to. On startup Frigate compares it against the format the running version expects, and if it is older it copies your config to `/config/backup_config.yaml`, rewrites it to the new format, and updates `version` as the final step. A config with no `version` key is assumed to predate 0.14 and is migrated from there.
|
||||||
|
|
||||||
|
Frigate manages this key for you, so do not set or edit it. Raising it makes Frigate skip migrations your config still needs, and lowering it re-runs migrations against config that has already been converted. Either can leave you with a config that no longer validates.
|
||||||
|
|
||||||
### Why does Frigate keep creating new tracked objects for my parked car?
|
### Why does Frigate keep creating new tracked objects for my parked car?
|
||||||
|
|
||||||
Stationary tracking is designed to _prevent_ this: a parked car should remain a single tracked object rather than generating new ones. If you're repeatedly getting new tracked objects for the same car, it's likely that Frigate is losing the object and re-detecting it as a new one.
|
Stationary tracking is designed to _prevent_ this: a parked car should remain a single tracked object rather than generating new ones. If you're repeatedly getting new tracked objects for the same car, it's likely that Frigate is losing the object and re-detecting it as a new one.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Deleting a group also clears any custom layout you saved for it.
|
|||||||
|
|
||||||
## Rearranging a camera group layout
|
## 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.
|
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.
|
||||||
|
|
||||||
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).
|
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
|
- 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.
|
- **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.
|
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.
|
||||||
|
|
||||||
## The single-camera view
|
## The single-camera view
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ SYSTEM_NAV: dict[str, tuple[str, str]] = {
|
|||||||
"environment_vars": ("System", "Environment variables"),
|
"environment_vars": ("System", "Environment variables"),
|
||||||
"telemetry": ("System", "Telemetry"),
|
"telemetry": ("System", "Telemetry"),
|
||||||
"birdseye": ("System", "Birdseye"),
|
"birdseye": ("System", "Birdseye"),
|
||||||
"detectors": ("System", "Detectors and model"),
|
"models": ("System", "Detection models"),
|
||||||
"model": ("System", "Detectors and model"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# All known top-level config section keys
|
# All known top-level config section keys
|
||||||
|
|||||||
@@ -219,6 +219,8 @@ hardware:
|
|||||||
- host: "/run/mxa_manager"
|
- host: "/run/mxa_manager"
|
||||||
container: "/run/mxa_manager"
|
container: "/run/mxa_manager"
|
||||||
comment: "MemryX manager"
|
comment: "MemryX manager"
|
||||||
|
privileged: true
|
||||||
|
privilegedReason: "required by MemryX to reach the max-manager"
|
||||||
|
|
||||||
- id: "axera"
|
- id: "axera"
|
||||||
label: "AXERA Accelerator"
|
label: "AXERA Accelerator"
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ export interface DeviceConfig {
|
|||||||
extraHosts?: string[];
|
extraHosts?: string[];
|
||||||
/** Security options, e.g. ["apparmor=unconfined"] */
|
/** Security options, e.g. ["apparmor=unconfined"] */
|
||||||
securityOpt?: string[];
|
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 */
|
/** Whether this device type needs the NVIDIA GPU config UI */
|
||||||
needsNvidiaConfig?: boolean;
|
needsNvidiaConfig?: boolean;
|
||||||
}
|
}
|
||||||
@@ -127,6 +131,10 @@ export interface HardwareOption {
|
|||||||
volumes?: VolumeMapping[];
|
volumes?: VolumeMapping[];
|
||||||
/** Extra environment variables */
|
/** Extra environment variables */
|
||||||
env?: Record<string, string>;
|
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 */
|
/** Port definition */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
DeviceConfig,
|
DeviceConfig,
|
||||||
DeviceMapping,
|
DeviceMapping,
|
||||||
|
HardwareOption,
|
||||||
VolumeMapping,
|
VolumeMapping,
|
||||||
} from "../config/types";
|
} from "../config/types";
|
||||||
import { hardwareMap } from "../config";
|
import { hardwareMap } from "../config";
|
||||||
@@ -194,13 +195,32 @@ function buildExtraHosts(device: DeviceConfig): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSecurityOpt(device: DeviceConfig): string[] {
|
function buildSecurityOpt(device: DeviceConfig): string[] {
|
||||||
if (!device.securityOpt?.length) return [];
|
// no-new-privileges is the baseline for every setup; device-specific entries
|
||||||
|
// are appended so only one security_opt key is ever emitted
|
||||||
return [
|
return [
|
||||||
" security_opt:",
|
" security_opt:",
|
||||||
...device.securityOpt.map((s) => ` - ${s}`),
|
" - no-new-privileges:true",
|
||||||
|
...(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
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -217,11 +237,14 @@ export function generateDockerCompose(input: GeneratorInput): string {
|
|||||||
const hwVolumes: VolumeMapping[] = [];
|
const hwVolumes: VolumeMapping[] = [];
|
||||||
const hwEnv: Record<string, string> = {};
|
const hwEnv: Record<string, string> = {};
|
||||||
|
|
||||||
|
const selectedHw: HardwareOption[] = [];
|
||||||
|
|
||||||
for (const hwId of input.selectedHardware) {
|
for (const hwId of input.selectedHardware) {
|
||||||
const hw = hardwareMap.get(hwId);
|
const hw = hardwareMap.get(hwId);
|
||||||
if (!hw) continue;
|
if (!hw) continue;
|
||||||
// Skip GPU device mapping for tensorrt images (it uses deploy instead)
|
// Skip GPU device mapping for tensorrt images (it uses deploy instead)
|
||||||
if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue;
|
if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue;
|
||||||
|
selectedHw.push(hw);
|
||||||
hwDevices.push(...(hw.devices ?? []));
|
hwDevices.push(...(hw.devices ?? []));
|
||||||
hwVolumes.push(...(hw.volumes ?? []));
|
hwVolumes.push(...(hw.volumes ?? []));
|
||||||
Object.assign(hwEnv, hw.env ?? {});
|
Object.assign(hwEnv, hw.env ?? {});
|
||||||
@@ -231,7 +254,7 @@ export function generateDockerCompose(input: GeneratorInput): string {
|
|||||||
"services:",
|
"services:",
|
||||||
" frigate:",
|
" frigate:",
|
||||||
" container_name: frigate",
|
" container_name: frigate",
|
||||||
" privileged: true # This may not be necessary for all setups",
|
...buildPrivileged(device, selectedHw),
|
||||||
" restart: unless-stopped",
|
" restart: unless-stopped",
|
||||||
" stop_grace_period: 30s # Allow enough time to shut down the various services",
|
" stop_grace_period: 30s # Allow enough time to shut down the various services",
|
||||||
...buildImage(device),
|
...buildImage(device),
|
||||||
|
|||||||
Vendored
+114
-9
@@ -1476,10 +1476,12 @@ paths:
|
|||||||
- Classification
|
- Classification
|
||||||
summary: Get custom classification attributes
|
summary: Get custom classification attributes
|
||||||
description: |-
|
description: |-
|
||||||
**Access:** Admin role required.
|
**Access:** Any authenticated user.
|
||||||
|
|
||||||
Returns custom classification attributes for a given object type.
|
Returns custom classification attributes for a given object type.
|
||||||
Only includes models with classification_type set to 'attribute'.
|
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.
|
By default returns a flat sorted list of all attribute labels.
|
||||||
If group_by_model is true, returns attributes grouped by model name.
|
If group_by_model is true, returns attributes grouped by model name.
|
||||||
operationId: get_custom_attributes_classification_attributes_get
|
operationId: get_custom_attributes_classification_attributes_get
|
||||||
@@ -1510,8 +1512,8 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/HTTPValidationError'
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
security:
|
security:
|
||||||
- frigateAdminAuth: []
|
- frigateUserAuth: []
|
||||||
x-required-role: admin
|
x-required-role: any
|
||||||
/classification/{name}/train:
|
/classification/{name}/train:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -2308,15 +2310,15 @@ paths:
|
|||||||
$ref: '#/components/schemas/HTTPValidationError'
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
security:
|
security:
|
||||||
- frigateUserAuth: []
|
- frigateUserAuth: []
|
||||||
x-required-role: any
|
x-required-role: camera
|
||||||
description: '**Access:** Any authenticated user.'
|
description: '**Access:** Authenticated user with access to the referenced camera.'
|
||||||
/review/summarize/start/{start_ts}/end/{end_ts}:
|
/review/summarize/start/{start_ts}/end/{end_ts}:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- Review
|
- Review
|
||||||
summary: Generate Review Summary
|
summary: Generate Review Summary
|
||||||
description: |-
|
description: |-
|
||||||
**Access:** Admin role required.
|
**Access:** Authenticated user with access to all cameras.
|
||||||
|
|
||||||
Use GenAI to summarize review items over a period of time.
|
Use GenAI to summarize review items over a period of time.
|
||||||
operationId:
|
operationId:
|
||||||
@@ -2347,8 +2349,8 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/HTTPValidationError'
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
security:
|
security:
|
||||||
- frigateAdminAuth: []
|
- frigateUserAuth: []
|
||||||
x-required-role: admin
|
x-required-role: all_cameras
|
||||||
/:
|
/:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -4010,6 +4012,49 @@ paths:
|
|||||||
security:
|
security:
|
||||||
- frigateAdminAuth: []
|
- frigateAdminAuth: []
|
||||||
x-required-role: admin
|
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:
|
/events:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -7263,7 +7308,9 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/DebugReplayStartResponse'
|
$ref: '#/components/schemas/DebugReplayStartResponse'
|
||||||
'400':
|
'400':
|
||||||
description: Invalid camera, time range, or no recordings
|
description: Invalid camera or time range
|
||||||
|
'404':
|
||||||
|
description: No recordings in the requested time range
|
||||||
'409':
|
'409':
|
||||||
description: A replay session is already active
|
description: A replay session is already active
|
||||||
'422':
|
'422':
|
||||||
@@ -7840,6 +7887,46 @@ components:
|
|||||||
required:
|
required:
|
||||||
- ids
|
- ids
|
||||||
title: DeleteFaceImagesBody
|
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:
|
EventCreateResponse:
|
||||||
properties:
|
properties:
|
||||||
success:
|
success:
|
||||||
@@ -8567,6 +8654,24 @@ components:
|
|||||||
title: Detail
|
title: Detail
|
||||||
type: object
|
type: object
|
||||||
title: HTTPValidationError
|
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:
|
Last24HoursReview:
|
||||||
properties:
|
properties:
|
||||||
reviewed_alert:
|
reviewed_alert:
|
||||||
|
|||||||
+11
-1
@@ -1330,8 +1330,18 @@ def categorized_object_names(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def get_audio_labels():
|
def get_audio_labels(request: Request):
|
||||||
labels = load_labels("/audio-labelmap.txt", prefill=521)
|
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)
|
return JSONResponse(content=labels)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+33
-3
@@ -86,6 +86,7 @@ def require_admin_by_default():
|
|||||||
"/categorized_object_names",
|
"/categorized_object_names",
|
||||||
"/plus/models",
|
"/plus/models",
|
||||||
"/recognized_license_plates",
|
"/recognized_license_plates",
|
||||||
|
"/classification/attributes",
|
||||||
"/timeline",
|
"/timeline",
|
||||||
"/timeline/hourly",
|
"/timeline/hourly",
|
||||||
"/recordings/storage",
|
"/recordings/storage",
|
||||||
@@ -858,9 +859,12 @@ def login(request: Request, body: AppPostLoginBody):
|
|||||||
user = body.user
|
user = body.user
|
||||||
password = body.password
|
password = body.password
|
||||||
|
|
||||||
|
remote_addr = get_remote_addr(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db_user: User = User.get_by_id(user)
|
db_user: User = User.get_by_id(user)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
|
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
|
||||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||||
|
|
||||||
password_hash = db_user.password_hash
|
password_hash = db_user.password_hash
|
||||||
@@ -888,6 +892,10 @@ def login(request: Request, body: AppPostLoginBody):
|
|||||||
request.app.frigate_config.auth.admin_first_time_login = False
|
request.app.frigate_config.auth.admin_first_time_login = False
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"Login failed for user '{user}' (invalid password) from {remote_addr}"
|
||||||
|
)
|
||||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||||
|
|
||||||
|
|
||||||
@@ -972,6 +980,7 @@ def delete_user(request: Request, username: str):
|
|||||||
summary="Update user password",
|
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.",
|
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(
|
async def update_password(
|
||||||
request: Request,
|
request: Request,
|
||||||
username: str,
|
username: str,
|
||||||
@@ -985,10 +994,11 @@ async def update_password(
|
|||||||
current_username = current_user.get("username")
|
current_username = current_user.get("username")
|
||||||
current_role = current_user.get("role")
|
current_role = current_user.get("role")
|
||||||
|
|
||||||
# viewers can only change their own password
|
# Only admins may target another account. This has to cover every non-admin
|
||||||
if current_role == "viewer" and current_username != username:
|
# role rather than just viewer, since custom roles are arbitrary names
|
||||||
|
if current_role != "admin" and current_username != username:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403, detail="Viewers can only update their own password"
|
status_code=403, detail="Users can only update their own password"
|
||||||
)
|
)
|
||||||
|
|
||||||
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
||||||
@@ -1252,3 +1262,23 @@ async def get_allowed_cameras_for_filter(request: Request):
|
|||||||
all_camera_names = set(request.app.frigate_config.cameras.keys())
|
all_camera_names = set(request.app.frigate_config.cameras.keys())
|
||||||
roles_dict = request.app.frigate_config.auth.roles
|
roles_dict = request.app.frigate_config.auth.roles
|
||||||
return User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from frigate.config.camera.updater import (
|
|||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdateTopic,
|
CameraConfigUpdateTopic,
|
||||||
)
|
)
|
||||||
from frigate.config.env import substitute_frigate_vars
|
from frigate.config.env import UnknownVariableError, substitute_frigate_vars
|
||||||
from frigate.models import User
|
from frigate.models import User
|
||||||
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
|
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
|
||||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
from frigate.util.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:
|
if src:
|
||||||
try:
|
try:
|
||||||
resolved_src = substitute_frigate_vars(src)
|
resolved_src = substitute_frigate_vars(src)
|
||||||
except KeyError:
|
except UnknownVariableError:
|
||||||
resolved_src = src
|
resolved_src = src
|
||||||
|
|
||||||
if is_restricted_go2rtc_source(resolved_src):
|
if is_restricted_go2rtc_source(resolved_src):
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ from typing import Any
|
|||||||
import cv2
|
import cv2
|
||||||
from fastapi import APIRouter, Depends, Request, UploadFile
|
from fastapi import APIRouter, Depends, Request, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist, fn
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.api.auth import require_role
|
from frigate.api.auth import (
|
||||||
|
allow_any_authenticated,
|
||||||
|
get_allowed_cameras_for_filter,
|
||||||
|
require_role,
|
||||||
|
)
|
||||||
from frigate.api.defs.request.classification_body import (
|
from frigate.api.defs.request.classification_body import (
|
||||||
AudioTranscriptionBody,
|
AudioTranscriptionBody,
|
||||||
DeleteFaceImagesBody,
|
DeleteFaceImagesBody,
|
||||||
@@ -739,18 +743,81 @@ 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(
|
@router.get(
|
||||||
"/classification/attributes",
|
"/classification/attributes",
|
||||||
|
dependencies=[Depends(allow_any_authenticated())],
|
||||||
summary="Get custom classification attributes",
|
summary="Get custom classification attributes",
|
||||||
description="""Returns custom classification attributes for a given object type.
|
description="""Returns custom classification attributes for a given object type.
|
||||||
Only includes models with classification_type set to 'attribute'.
|
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.
|
By default returns a flat sorted list of all attribute labels.
|
||||||
If group_by_model is true, returns attributes grouped by model name.""",
|
If group_by_model is true, returns attributes grouped by model name.""",
|
||||||
)
|
)
|
||||||
def get_custom_attributes(
|
def get_custom_attributes(
|
||||||
request: Request, object_type: str = None, group_by_model: bool = False
|
request: Request,
|
||||||
|
object_type: str = None,
|
||||||
|
group_by_model: bool = False,
|
||||||
|
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
|
||||||
):
|
):
|
||||||
models_with_attributes = {}
|
models_with_attributes = {}
|
||||||
|
objects_by_model = {}
|
||||||
|
|
||||||
for (
|
for (
|
||||||
model_key,
|
model_key,
|
||||||
@@ -781,6 +848,32 @@ def get_custom_attributes(
|
|||||||
if attributes:
|
if attributes:
|
||||||
model_name = model_config.name or model_key
|
model_name = model_config.name or model_key
|
||||||
models_with_attributes[model_name] = sorted(attributes)
|
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:
|
if group_by_model:
|
||||||
return JSONResponse(content=models_with_attributes)
|
return JSONResponse(content=models_with_attributes)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from frigate.api.auth import require_role
|
|||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.jobs.debug_replay import (
|
from frigate.jobs.debug_replay import (
|
||||||
ExportDebugReplaySource,
|
ExportDebugReplaySource,
|
||||||
|
NoRecordingsError,
|
||||||
RecordingDebugReplaySource,
|
RecordingDebugReplaySource,
|
||||||
start_debug_replay_job,
|
start_debug_replay_job,
|
||||||
)
|
)
|
||||||
@@ -74,7 +75,8 @@ class DebugReplayStopResponse(BaseModel):
|
|||||||
response_model=DebugReplayStartResponse,
|
response_model=DebugReplayStartResponse,
|
||||||
status_code=202,
|
status_code=202,
|
||||||
responses={
|
responses={
|
||||||
400: {"description": "Invalid camera, time range, or no recordings"},
|
400: {"description": "Invalid camera or time range"},
|
||||||
|
404: {"description": "No recordings in the requested time range"},
|
||||||
409: {"description": "A replay session is already active"},
|
409: {"description": "A replay session is already active"},
|
||||||
},
|
},
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
@@ -113,6 +115,14 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
|||||||
},
|
},
|
||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
except NoRecordingsError:
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": "No recordings found in the selected time range",
|
||||||
|
},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.exception("Rejected debug replay start request")
|
logger.exception("Rejected debug replay start request")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class Tags(Enum):
|
|||||||
chat = "Chat"
|
chat = "Chat"
|
||||||
events = "Events"
|
events = "Events"
|
||||||
export = "Export"
|
export = "Export"
|
||||||
|
hardware = "Hardware"
|
||||||
classification = "Classification"
|
classification = "Classification"
|
||||||
logs = "Logs"
|
logs = "Logs"
|
||||||
media = "Media"
|
media = "Media"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from frigate.api import (
|
|||||||
debug_replay,
|
debug_replay,
|
||||||
event,
|
event,
|
||||||
export,
|
export,
|
||||||
|
hardware,
|
||||||
media,
|
media,
|
||||||
motion_search,
|
motion_search,
|
||||||
notification,
|
notification,
|
||||||
@@ -145,6 +146,7 @@ def create_fastapi_app(
|
|||||||
app.include_router(preview.router)
|
app.include_router(preview.router)
|
||||||
app.include_router(notification.router)
|
app.include_router(notification.router)
|
||||||
app.include_router(export.router)
|
app.include_router(export.router)
|
||||||
|
app.include_router(hardware.router)
|
||||||
app.include_router(event.router)
|
app.include_router(event.router)
|
||||||
app.include_router(media.router)
|
app.include_router(media.router)
|
||||||
app.include_router(motion_search.router)
|
app.include_router(motion_search.router)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""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)
|
||||||
+61
-23
@@ -6,11 +6,13 @@ import logging
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path as FilePath
|
from pathlib import Path as FilePath
|
||||||
from typing import Any
|
from typing import IO, Any
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
@@ -47,6 +49,7 @@ from frigate.const import (
|
|||||||
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
||||||
from frigate.output.preview import get_most_recent_preview_frame
|
from frigate.output.preview import get_most_recent_preview_frame
|
||||||
from frigate.track.object_processing import TrackedObjectProcessor
|
from frigate.track.object_processing import TrackedObjectProcessor
|
||||||
|
from frigate.util.ffmpeg import terminate_ffmpeg_stream
|
||||||
from frigate.util.file import (
|
from frigate.util.file import (
|
||||||
get_event_snapshot_bytes,
|
get_event_snapshot_bytes,
|
||||||
get_event_snapshot_path,
|
get_event_snapshot_path,
|
||||||
@@ -70,6 +73,12 @@ logger = logging.getLogger(__name__)
|
|||||||
# normal hour needs ~360, one clip per recording file
|
# normal hour needs ~360, one clip per recording file
|
||||||
NGINX_VOD_MAX_CLIPS = 1080
|
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):
|
class VodStreamPreference(str, Enum):
|
||||||
"""Stream pin for the path-segment VOD route.
|
"""Stream pin for the path-segment VOD route.
|
||||||
@@ -465,6 +474,53 @@ 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(
|
@router.get(
|
||||||
"/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4",
|
"/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4",
|
||||||
dependencies=[Depends(require_camera_access)],
|
dependencies=[Depends(require_camera_access)],
|
||||||
@@ -476,26 +532,6 @@ async def recording_clip(
|
|||||||
start_ts: float,
|
start_ts: float,
|
||||||
end_ts: float,
|
end_ts: float,
|
||||||
):
|
):
|
||||||
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
|
|
||||||
|
|
||||||
def get_clip_query(stream_type: str):
|
def get_clip_query(stream_type: str):
|
||||||
return (
|
return (
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
@@ -529,7 +565,9 @@ async def recording_clip(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
|
file_name = sanitize_filename(
|
||||||
|
f"playlist_{camera_name}_{start_ts}-{end_ts}_{os.urandom(4).hex()}.txt"
|
||||||
|
)
|
||||||
file_path = os.path.join(CACHE_DIR, file_name)
|
file_path = os.path.join(CACHE_DIR, file_name)
|
||||||
with open(file_path, "w") as file:
|
with open(file_path, "w") as file:
|
||||||
clip: Recordings
|
clip: Recordings
|
||||||
@@ -577,7 +615,7 @@ async def recording_clip(
|
|||||||
]
|
]
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
run_download(ffmpeg_cmd, file_path),
|
_run_clip_download(ffmpeg_cmd, file_path),
|
||||||
media_type="video/mp4",
|
media_type="video/mp4",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Notification apis."""
|
"""Notification apis."""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
@@ -19,6 +21,95 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(tags=[Tags.notifications])
|
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(
|
@router.get(
|
||||||
"/notifications/pubkey",
|
"/notifications/pubkey",
|
||||||
@@ -71,6 +162,17 @@ def register_notifications(request: Request, body: dict = None):
|
|||||||
status_code=400,
|
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:
|
try:
|
||||||
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
|
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
|
||||||
User.username == username
|
User.username == username
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from frigate.api.auth import (
|
|||||||
get_allowed_cameras_for_filter,
|
get_allowed_cameras_for_filter,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
require_camera_access,
|
require_camera_access,
|
||||||
|
require_full_camera_access,
|
||||||
require_role,
|
require_role,
|
||||||
)
|
)
|
||||||
from frigate.api.defs.query.review_query_parameters import (
|
from frigate.api.defs.query.review_query_parameters import (
|
||||||
@@ -712,6 +713,7 @@ async def get_review(request: Request, review_id: str):
|
|||||||
dependencies=[Depends(allow_any_authenticated())],
|
dependencies=[Depends(allow_any_authenticated())],
|
||||||
)
|
)
|
||||||
async def set_not_reviewed(
|
async def set_not_reviewed(
|
||||||
|
request: Request,
|
||||||
review_id: str,
|
review_id: str,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -730,6 +732,8 @@ async def set_not_reviewed(
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await require_camera_access(review.camera, request=request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_review = UserReviewStatus.get(
|
user_review = UserReviewStatus.get(
|
||||||
UserReviewStatus.user_id == user_id,
|
UserReviewStatus.user_id == user_id,
|
||||||
@@ -746,9 +750,12 @@ 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(
|
@router.post(
|
||||||
"/review/summarize/start/{start_ts}/end/{end_ts}",
|
"/review/summarize/start/{start_ts}/end/{end_ts}",
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_full_camera_access)],
|
||||||
description="Use GenAI to summarize review items over a period of time.",
|
description="Use GenAI to summarize review items over a period of time.",
|
||||||
)
|
)
|
||||||
def generate_review_summary(request: Request, start_ts: float, end_ts: float):
|
def generate_review_summary(request: Request, start_ts: float, end_ts: float):
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ from frigate.timeline import TimelineProcessor
|
|||||||
from frigate.track.object_processing import TrackedObjectProcessor
|
from frigate.track.object_processing import TrackedObjectProcessor
|
||||||
from frigate.util.builtin import empty_and_close_queue
|
from frigate.util.builtin import empty_and_close_queue
|
||||||
from frigate.util.image import UntrackedSharedMemory
|
from frigate.util.image import UntrackedSharedMemory
|
||||||
|
from frigate.util.ownership import chown_to_runtime
|
||||||
from frigate.util.process import FrigateProcess
|
from frigate.util.process import FrigateProcess
|
||||||
from frigate.util.services import set_file_limit
|
from frigate.util.services import set_file_limit
|
||||||
from frigate.version import VERSION
|
from frigate.version import VERSION
|
||||||
@@ -149,6 +150,7 @@ class FrigateApp:
|
|||||||
if not os.path.exists(d) and not os.path.islink(d):
|
if not os.path.exists(d) and not os.path.islink(d):
|
||||||
logger.info(f"Creating directory: {d}")
|
logger.info(f"Creating directory: {d}")
|
||||||
os.makedirs(d, exist_ok=True)
|
os.makedirs(d, exist_ok=True)
|
||||||
|
chown_to_runtime(d)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Skipping directory: {d}")
|
logger.debug(f"Skipping directory: {d}")
|
||||||
|
|
||||||
@@ -340,6 +342,7 @@ class FrigateApp:
|
|||||||
self.ptz_metrics,
|
self.ptz_metrics,
|
||||||
comms,
|
comms,
|
||||||
)
|
)
|
||||||
|
self.dispatcher.start_communicators()
|
||||||
|
|
||||||
def init_profile_manager(self) -> None:
|
def init_profile_manager(self) -> None:
|
||||||
self.profile_manager = ProfileManager(
|
self.profile_manager = ProfileManager(
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
|
|
||||||
|
|
||||||
class Communicator(ABC):
|
class Communicator(ABC):
|
||||||
"""pub/sub model via specific protocol."""
|
"""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
|
@abstractmethod
|
||||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||||
"""Send data via specific protocol."""
|
"""Send data via specific protocol."""
|
||||||
|
|||||||
+129
-68
@@ -6,6 +6,8 @@ import logging
|
|||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from peewee import IntegrityError
|
||||||
|
|
||||||
from frigate.camera import PTZMetrics
|
from frigate.camera import PTZMetrics
|
||||||
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
|
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
|
||||||
from frigate.comms.base_communicator import Communicator
|
from frigate.comms.base_communicator import Communicator
|
||||||
@@ -49,6 +51,11 @@ from frigate.util.services import restart_frigate
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class Dispatcher:
|
||||||
"""Handle communication between Frigate and communicators."""
|
"""Handle communication between Frigate and communicators."""
|
||||||
@@ -103,12 +110,114 @@ class Dispatcher:
|
|||||||
}
|
}
|
||||||
self.profile_manager: ProfileManager | None = None
|
self.profile_manager: ProfileManager | None = None
|
||||||
|
|
||||||
for comm in self.comms:
|
|
||||||
comm.subscribe(self._receive)
|
|
||||||
|
|
||||||
self.web_push_client = next(
|
self.web_push_client = next(
|
||||||
(comm for comm in communicators if isinstance(comm, WebPushClient)), None
|
(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,
|
||||||
|
)
|
||||||
if self.web_push_client is not None:
|
if self.web_push_client is not None:
|
||||||
self.web_push_client.set_suspension_broadcaster(self.publish)
|
self.web_push_client.set_suspension_broadcaster(self.publish)
|
||||||
|
|
||||||
@@ -127,17 +236,11 @@ class Dispatcher:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if command_type == "set":
|
if command_type == "set":
|
||||||
# Commands that require a sub-command (mask/zone name)
|
|
||||||
sub_command_required = {
|
|
||||||
"motion_mask",
|
|
||||||
"object_mask",
|
|
||||||
"zone",
|
|
||||||
}
|
|
||||||
if sub_command:
|
if sub_command:
|
||||||
self._camera_settings_handlers[command](
|
self._camera_settings_handlers[command](
|
||||||
camera_name, sub_command, payload
|
camera_name, sub_command, payload
|
||||||
)
|
)
|
||||||
elif command in sub_command_required:
|
elif command in SUB_COMMAND_TOPICS:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Command %s requires a sub-command (mask/zone name)",
|
"Command %s requires a sub-command (mask/zone name)",
|
||||||
command,
|
command,
|
||||||
@@ -153,7 +256,21 @@ class Dispatcher:
|
|||||||
restart_frigate()
|
restart_frigate()
|
||||||
|
|
||||||
def handle_insert_many_recordings() -> None:
|
def handle_insert_many_recordings() -> None:
|
||||||
|
try:
|
||||||
Recordings.insert_many(payload).execute()
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
def handle_request_region_grid() -> Any:
|
def handle_request_region_grid() -> Any:
|
||||||
camera = payload
|
camera = payload
|
||||||
@@ -272,67 +389,11 @@ class Dispatcher:
|
|||||||
def handle_birdseye_layout() -> None:
|
def handle_birdseye_layout() -> None:
|
||||||
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
|
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:
|
def handle_notification_test() -> None:
|
||||||
self.publish("notification_test", "Test notification")
|
self.publish("notification_test", "Test notification")
|
||||||
|
|
||||||
# Dictionary mapping topic to handlers
|
# Dictionary mapping topic to handlers
|
||||||
topic_handlers = {
|
topic_handlers: dict[str, Callable[[], Any]] = {
|
||||||
INSERT_MANY_RECORDINGS: handle_insert_many_recordings,
|
INSERT_MANY_RECORDINGS: handle_insert_many_recordings,
|
||||||
REQUEST_REGION_GRID: handle_request_region_grid,
|
REQUEST_REGION_GRID: handle_request_region_grid,
|
||||||
INSERT_PREVIEW: handle_insert_preview,
|
INSERT_PREVIEW: handle_insert_preview,
|
||||||
@@ -355,7 +416,7 @@ class Dispatcher:
|
|||||||
"jobState": handle_job_state,
|
"jobState": handle_job_state,
|
||||||
"audioTranscriptionState": handle_audio_transcription_state,
|
"audioTranscriptionState": handle_audio_transcription_state,
|
||||||
"birdseyeLayout": handle_birdseye_layout,
|
"birdseyeLayout": handle_birdseye_layout,
|
||||||
"onConnect": handle_on_connect,
|
"onConnect": self.publish_runtime_snapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
if topic.endswith("set") or topic.endswith("ptz") or topic.endswith("suspend"):
|
if topic.endswith("set") or topic.endswith("ptz") or topic.endswith("suspend"):
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ SOCKET_REP_REQ = "ipc:///tmp/cache/comms"
|
|||||||
|
|
||||||
class InterProcessCommunicator(Communicator):
|
class InterProcessCommunicator(Communicator):
|
||||||
def __init__(self) -> None:
|
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.context = zmq.Context()
|
||||||
self.socket = self.context.socket(zmq.REP)
|
self.socket = self.context.socket(zmq.REP)
|
||||||
self.socket.bind(SOCKET_REP_REQ)
|
self.socket.bind(SOCKET_REP_REQ)
|
||||||
self.stop_event: MpEvent = mp.Event()
|
self.stop_event: MpEvent = mp.Event()
|
||||||
|
self.reader_thread: threading.Thread | None = None
|
||||||
|
|
||||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||||
"""There is no communication back to the processes."""
|
"""There is no communication back to the processes."""
|
||||||
@@ -29,6 +32,8 @@ class InterProcessCommunicator(Communicator):
|
|||||||
|
|
||||||
def subscribe(self, receiver: Callable) -> None:
|
def subscribe(self, receiver: Callable) -> None:
|
||||||
self._dispatcher = receiver
|
self._dispatcher = receiver
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
self.reader_thread = threading.Thread(target=self.read)
|
self.reader_thread = threading.Thread(target=self.read)
|
||||||
self.reader_thread.start()
|
self.reader_thread.start()
|
||||||
|
|
||||||
@@ -61,6 +66,7 @@ class InterProcessCommunicator(Communicator):
|
|||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.stop_event.set()
|
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.socket.close(linger=0)
|
||||||
self.context.destroy(linger=0)
|
self.context.destroy(linger=0)
|
||||||
|
|||||||
+669
-168
@@ -1,7 +1,12 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import queue
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
from paho.mqtt.enums import CallbackAPIVersion
|
from paho.mqtt.enums import CallbackAPIVersion
|
||||||
@@ -9,8 +14,25 @@ from paho.mqtt.enums import CallbackAPIVersion
|
|||||||
from frigate.comms.base_communicator import Communicator
|
from frigate.comms.base_communicator import Communicator
|
||||||
from frigate.config import FrigateConfig, birdseye_modes_to_mqtt_payload
|
from frigate.config import FrigateConfig, birdseye_modes_to_mqtt_payload
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class MqttClient(Communicator):
|
||||||
"""Frigate wrapper for mqtt client."""
|
"""Frigate wrapper for mqtt client."""
|
||||||
@@ -19,28 +41,80 @@ class MqttClient(Communicator):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.mqtt_config = config.mqtt
|
self.mqtt_config = config.mqtt
|
||||||
self.connected = False
|
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:
|
def subscribe(self, receiver: Callable) -> None:
|
||||||
"""Wrapper for allowing dispatcher to subscribe."""
|
"""Wrapper for allowing dispatcher to subscribe."""
|
||||||
self._dispatcher = receiver
|
self._dispatcher = receiver
|
||||||
self._start()
|
|
||||||
|
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()
|
||||||
|
|
||||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||||
"""Wrapper for publishing when client is in valid state."""
|
"""Wrapper for publishing when client is in valid state."""
|
||||||
|
full_topic = f"{self.mqtt_config.topic_prefix}/{topic}"
|
||||||
|
|
||||||
if not self.connected:
|
if not self.connected:
|
||||||
logger.debug(f"Unable to publish to {topic}: client is not connected")
|
if retain:
|
||||||
|
self._queue_retained(full_topic, payload, retain)
|
||||||
|
else:
|
||||||
|
logger.debug("Unable to publish to %s: client is not connected", topic)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.client.publish(
|
self._publish_queue.put(QueuedPublish(full_topic, payload, retain))
|
||||||
f"{self.mqtt_config.topic_prefix}/{topic}",
|
|
||||||
payload,
|
|
||||||
qos=self.config.mqtt.qos,
|
|
||||||
retain=retain,
|
|
||||||
)
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.publish("available", "stopped", retain=True)
|
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()
|
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
|
||||||
|
|
||||||
def _notifications_enabled_in_config(self) -> bool:
|
def _notifications_enabled_in_config(self) -> bool:
|
||||||
"""Whether notifications are configured globally or on any camera.
|
"""Whether notifications are configured globally or on any camera.
|
||||||
@@ -54,17 +128,17 @@ class MqttClient(Communicator):
|
|||||||
for cam in self.config.cameras.values()
|
for cam in self.config.cameras.values()
|
||||||
)
|
)
|
||||||
|
|
||||||
def _set_initial_topics(self) -> None:
|
def _publish_retained_state(self) -> None:
|
||||||
"""Set initial state topics."""
|
"""Publish retained MQTT state after a successful subscribe."""
|
||||||
for camera_name, camera in self.config.cameras.items():
|
for camera_name, camera in self.config.cameras.items():
|
||||||
self.publish(
|
self.publish(
|
||||||
f"{camera_name}/enabled/state",
|
f"{camera_name}/enabled/state",
|
||||||
"ON" if camera.enabled_in_config else "OFF",
|
"ON" if camera.enabled else "OFF",
|
||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
f"{camera_name}/recordings/state",
|
f"{camera_name}/recordings/state",
|
||||||
"ON" if camera.record.enabled_in_config else "OFF",
|
"ON" if camera.record.enabled else "OFF",
|
||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
@@ -74,7 +148,7 @@ class MqttClient(Communicator):
|
|||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
f"{camera_name}/audio/state",
|
f"{camera_name}/audio/state",
|
||||||
"ON" if camera.audio.enabled_in_config else "OFF",
|
"ON" if camera.audio.enabled else "OFF",
|
||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
@@ -89,7 +163,7 @@ class MqttClient(Communicator):
|
|||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
f"{camera_name}/motion/state",
|
f"{camera_name}/motion/state",
|
||||||
"ON",
|
"ON" if camera.motion.enabled else "OFF",
|
||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
@@ -99,7 +173,7 @@ class MqttClient(Communicator):
|
|||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
f"{camera_name}/ptz_autotracker/state",
|
f"{camera_name}/ptz_autotracker/state",
|
||||||
"ON" if camera.onvif.autotracking.enabled_in_config else "OFF",
|
"ON" if camera.onvif.autotracking.enabled else "OFF",
|
||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
@@ -133,22 +207,22 @@ class MqttClient(Communicator):
|
|||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
f"{camera_name}/review_alerts/state",
|
f"{camera_name}/review_alerts/state",
|
||||||
"ON" if camera.review.alerts.enabled_in_config else "OFF",
|
"ON" if camera.review.alerts.enabled else "OFF",
|
||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
f"{camera_name}/review_detections/state",
|
f"{camera_name}/review_detections/state",
|
||||||
"ON" if camera.review.detections.enabled_in_config else "OFF",
|
"ON" if camera.review.detections.enabled else "OFF",
|
||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
f"{camera_name}/object_descriptions/state",
|
f"{camera_name}/object_descriptions/state",
|
||||||
"ON" if camera.objects.genai.enabled_in_config else "OFF",
|
"ON" if camera.objects.genai.enabled else "OFF",
|
||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
f"{camera_name}/review_descriptions/state",
|
f"{camera_name}/review_descriptions/state",
|
||||||
"ON" if camera.review.genai.enabled_in_config else "OFF",
|
"ON" if camera.review.genai.enabled else "OFF",
|
||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -189,13 +263,521 @@ class MqttClient(Communicator):
|
|||||||
)
|
)
|
||||||
self.publish("available", "online", retain=True)
|
self.publish("available", "online", retain=True)
|
||||||
|
|
||||||
def on_mqtt_command(
|
def _create_client(self) -> mqtt.Client:
|
||||||
self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage
|
"""Build a fresh paho client for a single connect attempt."""
|
||||||
) -> None:
|
client = mqtt.Client(
|
||||||
self._dispatcher(
|
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||||
message.topic.replace(f"{self.mqtt_config.topic_prefix}/", "", 1),
|
client_id=self.mqtt_config.client_id,
|
||||||
message.payload.decode(),
|
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
|
||||||
|
) -> 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._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(
|
def _on_connect(
|
||||||
self,
|
self,
|
||||||
@@ -205,29 +787,11 @@ class MqttClient(Communicator):
|
|||||||
reason_code: mqtt.ReasonCode, # type: ignore[name-defined]
|
reason_code: mqtt.ReasonCode, # type: ignore[name-defined]
|
||||||
properties: Any,
|
properties: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mqtt connection callback."""
|
"""Handle broker connect notifications from paho."""
|
||||||
threading.current_thread().name = "mqtt"
|
if self._is_success_reason_code(reason_code):
|
||||||
if reason_code != 0:
|
self._callback_queue.put(("connect", reason_code))
|
||||||
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:
|
else:
|
||||||
logger.error(
|
self._callback_queue.put(("connect_failure", reason_code))
|
||||||
"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(
|
def _on_disconnect(
|
||||||
self,
|
self,
|
||||||
@@ -237,126 +801,63 @@ class MqttClient(Communicator):
|
|||||||
reason_code: mqtt.ReasonCode, # type: ignore[name-defined]
|
reason_code: mqtt.ReasonCode, # type: ignore[name-defined]
|
||||||
properties: Any,
|
properties: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mqtt disconnection callback."""
|
"""Handle broker disconnect notifications from paho."""
|
||||||
self.connected = False
|
self._callback_queue.put(("disconnect", reason_code))
|
||||||
logger.error("MQTT disconnected")
|
|
||||||
|
|
||||||
def _start(self) -> None:
|
def _on_subscribe(
|
||||||
"""Start mqtt client."""
|
self,
|
||||||
self.client = mqtt.Client(
|
client: mqtt.Client,
|
||||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
userdata: Any,
|
||||||
client_id=self.mqtt_config.client_id,
|
mid: int,
|
||||||
)
|
reason_codes: list[mqtt.ReasonCode], # type: ignore[name-defined]
|
||||||
self.client.on_connect = self._on_connect
|
properties: Any,
|
||||||
self.client.on_disconnect = self._on_disconnect
|
) -> None:
|
||||||
self.client.will_set(
|
"""Handle subscribe acknowledgements from paho."""
|
||||||
self.mqtt_config.topic_prefix + "/available",
|
self._callback_queue.put(("subscribed", mid, reason_codes))
|
||||||
payload="offline",
|
|
||||||
qos=1,
|
|
||||||
retain=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# register callbacks
|
def _on_publish(
|
||||||
callback_types = [
|
self,
|
||||||
"enabled",
|
client: mqtt.Client,
|
||||||
"recordings",
|
userdata: Any,
|
||||||
"snapshots",
|
mid: int,
|
||||||
"detect",
|
reason_code: mqtt.ReasonCode, # type: ignore[name-defined]
|
||||||
"audio",
|
properties: Any,
|
||||||
"audio_transcription",
|
) -> None:
|
||||||
"motion",
|
"""Handle publish acknowledgements from paho.
|
||||||
"improve_contrast",
|
|
||||||
"ptz_autotracker",
|
|
||||||
"motion_threshold",
|
|
||||||
"motion_contour_area",
|
|
||||||
"birdseye",
|
|
||||||
"birdseye_modes",
|
|
||||||
"review_alerts",
|
|
||||||
"review_detections",
|
|
||||||
"object_descriptions",
|
|
||||||
"review_descriptions",
|
|
||||||
"notifications",
|
|
||||||
]
|
|
||||||
|
|
||||||
for name in self.config.cameras.keys():
|
Only tracked retained messages need an event. At the default qos 0
|
||||||
for callback in callback_types:
|
nothing is tracked, so this stays off the hot publish path.
|
||||||
self.client.message_callback_add(
|
"""
|
||||||
f"{self.mqtt_config.topic_prefix}/{name}/{callback}/set",
|
with self._retained_lock:
|
||||||
self.on_mqtt_command,
|
if mid not in self._inflight_retained:
|
||||||
)
|
|
||||||
|
|
||||||
# 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
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ class RecordingsDataTypeEnum(str, Enum):
|
|||||||
|
|
||||||
|
|
||||||
class RecordingsDataPublisher(Publisher[Any]):
|
class RecordingsDataPublisher(Publisher[Any]):
|
||||||
"""Publishes latest recording data."""
|
"""Publishes latest recording data.
|
||||||
|
|
||||||
|
Payloads are (camera, stream_type, timestamp, cache_path) on every topic.
|
||||||
|
"""
|
||||||
|
|
||||||
topic_base = "recordings/"
|
topic_base = "recordings/"
|
||||||
|
|
||||||
|
|||||||
@@ -63,14 +63,8 @@ class WebPushClient(Communicator):
|
|||||||
self.last_notification_time: float = 0
|
self.last_notification_time: float = 0
|
||||||
self.user_cameras: dict[str, set[str]] = {}
|
self.user_cameras: dict[str, set[str]] = {}
|
||||||
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
|
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
|
||||||
self.notification_thread = threading.Thread(
|
self.notification_thread: threading.Thread | None = None
|
||||||
target=self._process_notifications, daemon=True
|
self.suspension_thread: threading.Thread | None = None
|
||||||
)
|
|
||||||
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:
|
if not self.config.notifications.email:
|
||||||
logger.warning("Email must be provided for push notifications to be sent.")
|
logger.warning("Email must be provided for push notifications to be sent.")
|
||||||
@@ -89,7 +83,9 @@ class WebPushClient(Communicator):
|
|||||||
# notification and auth config updater
|
# notification and auth config updater
|
||||||
self.global_config_subscriber = ConfigSubscriber("config/")
|
self.global_config_subscriber = ConfigSubscriber("config/")
|
||||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||||
self.config, self.config.cameras, [CameraConfigUpdateEnum.notifications]
|
self.config,
|
||||||
|
self.config.cameras,
|
||||||
|
[CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.notifications],
|
||||||
)
|
)
|
||||||
self._refresh_user_cameras()
|
self._refresh_user_cameras()
|
||||||
|
|
||||||
@@ -97,6 +93,16 @@ class WebPushClient(Communicator):
|
|||||||
"""Wrapper for allowing dispatcher to subscribe."""
|
"""Wrapper for allowing dispatcher to subscribe."""
|
||||||
pass
|
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:
|
def check_registrations(self) -> None:
|
||||||
# check for valid claim or create new one
|
# check for valid claim or create new one
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
@@ -213,6 +219,8 @@ class WebPushClient(Communicator):
|
|||||||
self.suspended_cameras[camera] = 0
|
self.suspended_cameras[camera] = 0
|
||||||
self.last_camera_notification_time[camera] = 0
|
self.last_camera_notification_time[camera] = 0
|
||||||
|
|
||||||
|
self._refresh_user_cameras()
|
||||||
|
|
||||||
if topic == "reviews":
|
if topic == "reviews":
|
||||||
decoded = json.loads(payload)
|
decoded = json.loads(payload)
|
||||||
camera = decoded["before"]["camera"]
|
camera = decoded["before"]["camera"]
|
||||||
@@ -603,4 +611,5 @@ class WebPushClient(Communicator):
|
|||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
logger.info("Closing notification queue")
|
logger.info("Closing notification queue")
|
||||||
|
if self.notification_thread is not None:
|
||||||
self.notification_thread.join()
|
self.notification_thread.join()
|
||||||
|
|||||||
@@ -466,7 +466,6 @@ class WebSocketClient(Communicator):
|
|||||||
|
|
||||||
def subscribe(self, receiver: Callable) -> None:
|
def subscribe(self, receiver: Callable) -> None:
|
||||||
self._dispatcher = receiver
|
self._dispatcher = receiver
|
||||||
self.start()
|
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
"""Start the websocket client."""
|
"""Start the websocket client."""
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ class AudioConfig(FrigateBaseModel):
|
|||||||
title="Listen types",
|
title="Listen types",
|
||||||
description="List of audio event types to detect (for example: bark, fire_alarm, speech, yell).",
|
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(
|
filters: dict[str, AudioFilterConfig] | None = Field(
|
||||||
None,
|
None,
|
||||||
title="Audio filters",
|
title="Audio filters",
|
||||||
|
|||||||
@@ -220,16 +220,21 @@ class CameraConfig(FrigateBaseModel):
|
|||||||
|
|
||||||
# add roles to the input if there is only one
|
# add roles to the input if there is only one
|
||||||
if len(config["ffmpeg"]["inputs"]) == 1:
|
if len(config["ffmpeg"]["inputs"]) == 1:
|
||||||
has_audio = "audio" in config["ffmpeg"]["inputs"][0].get("roles", [])
|
existing_roles = config["ffmpeg"]["inputs"][0].get("roles", [])
|
||||||
|
|
||||||
config["ffmpeg"]["inputs"][0]["roles"] = [
|
config["ffmpeg"]["inputs"][0]["roles"] = [
|
||||||
"record",
|
"record",
|
||||||
"detect",
|
"detect",
|
||||||
]
|
]
|
||||||
|
|
||||||
if has_audio:
|
if "audio" in existing_roles:
|
||||||
config["ffmpeg"]["inputs"][0]["roles"].append("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)
|
super().__init__(**config)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ class DetectConfig(FrigateBaseModel):
|
|||||||
title="Detect width",
|
title="Detect width",
|
||||||
description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
|
description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
|
||||||
)
|
)
|
||||||
scene: SceneEnum | None = Field(
|
scene: SceneEnum = Field(
|
||||||
default=None,
|
default=SceneEnum.all,
|
||||||
title="Detect scene",
|
title="Detect scene",
|
||||||
description="The environment this camera looks at, used to pick which of the configured models runs on it. Defaults to the model with a scene of 'all'.",
|
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(
|
fps: int = Field(
|
||||||
default=5,
|
default=5,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from enum import Enum
|
|||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from frigate.const import MAX_PRE_CAPTURE
|
from frigate.const import MAX_PRE_CAPTURE, STREAM_TYPE_SUB
|
||||||
from frigate.review.types import SeverityEnum
|
from frigate.review.types import SeverityEnum
|
||||||
|
|
||||||
from ..base import FrigateBaseModel
|
from ..base import FrigateBaseModel
|
||||||
@@ -191,6 +191,13 @@ class RecordConfig(FrigateBaseModel):
|
|||||||
description="Indicates whether recording was enabled in the original static configuration.",
|
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
|
@property
|
||||||
def effective_alert_days(self) -> float:
|
def effective_alert_days(self) -> float:
|
||||||
"""Alert retention extended to the sub stream window when sub is enabled.
|
"""Alert retention extended to the sub stream window when sub is enabled.
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ class CameraConfigUpdateSubscriber:
|
|||||||
return
|
return
|
||||||
elif update_type == CameraConfigUpdateEnum.remove:
|
elif update_type == CameraConfigUpdateEnum.remove:
|
||||||
self.config.cameras.pop(camera, None)
|
self.config.cameras.pop(camera, None)
|
||||||
|
self.config.drop_camera_model(camera)
|
||||||
self.camera_configs.pop(camera, None)
|
self.camera_configs.pop(camera, None)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+48
-15
@@ -63,7 +63,7 @@ from .classification import (
|
|||||||
SemanticSearchModelEnum,
|
SemanticSearchModelEnum,
|
||||||
)
|
)
|
||||||
from .database import DatabaseConfig
|
from .database import DatabaseConfig
|
||||||
from .env import EnvVars
|
from .env import EnvVars, reload_sources
|
||||||
from .logger import LoggerConfig
|
from .logger import LoggerConfig
|
||||||
from .mqtt import MqttConfig
|
from .mqtt import MqttConfig
|
||||||
from .network import NetworkingConfig
|
from .network import NetworkingConfig
|
||||||
@@ -269,6 +269,12 @@ def verify_config_roles(camera_config: CameraConfig) -> None:
|
|||||||
f"Camera {camera_config.name} has sub stream recording enabled, but record_sub is not assigned to an input."
|
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:
|
if camera_config.audio.enabled and "audio" not in assigned_roles:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Camera {camera_config.name} has audio events enabled, but audio is not assigned to an input."
|
f"Camera {camera_config.name} has audio events enabled, but audio is not assigned to an input."
|
||||||
@@ -687,13 +693,37 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
def model_for_camera(self, camera_name: str) -> ModelConfig:
|
def model_for_camera(self, camera_name: str) -> ModelConfig:
|
||||||
"""Get the detection model a camera runs on.
|
"""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:
|
Args:
|
||||||
camera_name: Name of the camera
|
camera_name: Name of the camera
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The model matching the camera's detect scene
|
The model matching the camera's detect scene
|
||||||
"""
|
"""
|
||||||
return self._camera_models[camera_name]
|
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]:
|
def devices_for_model(self, model: ModelConfig) -> list[DeviceSpec]:
|
||||||
"""Get the parsed hardware devices a model runs on.
|
"""Get the parsed hardware devices a model runs on.
|
||||||
@@ -807,37 +837,37 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
}
|
}
|
||||||
self._all_labels = labels
|
self._all_labels = labels
|
||||||
|
|
||||||
def _resolve_camera_model(self, name: str, scene: SceneEnum | None) -> ModelConfig:
|
def _resolve_camera_model(self, name: str, scene: SceneEnum) -> ModelConfig:
|
||||||
"""Resolve which model a camera runs on.
|
"""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:
|
Args:
|
||||||
name: Name of the camera
|
name: Name of the camera
|
||||||
scene: The camera's configured detect scene, if any
|
scene: The camera's detect scene, which defaults to 'all'
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The model the camera runs on
|
The model the camera runs on
|
||||||
"""
|
"""
|
||||||
by_scene = {model.scene: model for model in self.models}
|
by_scene = {model.scene: model for model in self.models}
|
||||||
|
|
||||||
if scene is not None:
|
|
||||||
model = by_scene.get(scene)
|
model = by_scene.get(scene)
|
||||||
|
|
||||||
if model is None:
|
if model is not None:
|
||||||
raise ValueError(
|
|
||||||
f"Camera '{name}' has a detect scene of '{scene.value}', but no model is configured for that scene."
|
|
||||||
)
|
|
||||||
|
|
||||||
return model
|
return model
|
||||||
|
|
||||||
default = by_scene.get(SceneEnum.all) or (
|
default = by_scene.get(SceneEnum.all)
|
||||||
self.models[0] if len(self.models) == 1 else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if default is None:
|
if default is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Camera '{name}' must set detect -> scene, because more than one model is configured and none of them uses a scene of 'all'."
|
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
|
return default
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
@@ -1283,6 +1313,9 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse(cls, config, *, is_json=None, safe_load=False, **context):
|
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 config is a file, read its contents.
|
||||||
if hasattr(config, "read"):
|
if hasattr(config, "read"):
|
||||||
fname = getattr(config, "name", None)
|
fname = getattr(config, "name", None)
|
||||||
|
|||||||
+192
-18
@@ -1,20 +1,193 @@
|
|||||||
|
"""Environment variable and secrets handling for the Frigate config."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from pydantic import AfterValidator, ValidationInfo
|
from pydantic import AfterValidator, ValidationInfo
|
||||||
|
from ruamel.yaml import YAML, YAMLError
|
||||||
|
|
||||||
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
|
from frigate.const import CONFIG_DIR
|
||||||
secrets_dir = os.environ.get("CREDENTIALS_DIRECTORY", "/run/secrets")
|
|
||||||
# read secret files as env vars too
|
logger = logging.getLogger(__name__)
|
||||||
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_"):
|
class UnknownVariableError(ValueError):
|
||||||
FRIGATE_ENV_VARS[secret_file] = (
|
"""Undefined {FRIGATE_*} placeholder. ValueError so pydantic names the field."""
|
||||||
Path(os.path.join(secrets_dir, secret_file)).read_text().strip()
|
|
||||||
|
|
||||||
|
# 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],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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.
|
# Matches a FRIGATE_* identifier following an opening brace.
|
||||||
_FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+")
|
_FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+")
|
||||||
@@ -29,12 +202,13 @@ def substitute_frigate_vars(value: str) -> str:
|
|||||||
|
|
||||||
* `{{` and `}}` collapse to literal `{` / `}` (the documented escape).
|
* `{{` and `}}` collapse to literal `{` / `}` (the documented escape).
|
||||||
* `{FRIGATE_NAME}` is replaced from `FRIGATE_ENV_VARS`; an unknown name
|
* `{FRIGATE_NAME}` is replaced from `FRIGATE_ENV_VARS`; an unknown name
|
||||||
raises `KeyError` to preserve the existing "Invalid substitution"
|
raises `UnknownVariableError` to preserve the existing "Invalid
|
||||||
error path.
|
substitution" error path.
|
||||||
* A `{` that begins `{FRIGATE_` but is not a well-formed
|
* A `{` that begins `{FRIGATE_` but is not a well-formed
|
||||||
`{FRIGATE_NAME}` placeholder raises `ValueError` (malformed
|
`{FRIGATE_NAME}` placeholder raises `ValueError` (malformed
|
||||||
placeholder). Callers that catch `KeyError` to allow unknown-var
|
placeholder). Callers that catch `UnknownVariableError` to allow
|
||||||
passthrough will still surface malformed syntax as an error.
|
unknown-var passthrough will still surface malformed syntax as an
|
||||||
|
error.
|
||||||
* Any other `{` or `}` is treated as a literal and passed through.
|
* Any other `{` or `}` is treated as a literal and passed through.
|
||||||
"""
|
"""
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
@@ -58,7 +232,10 @@ def substitute_frigate_vars(value: str) -> str:
|
|||||||
):
|
):
|
||||||
key = ident_match.group(0)
|
key = ident_match.group(0)
|
||||||
if key not in FRIGATE_ENV_VARS:
|
if key not in FRIGATE_ENV_VARS:
|
||||||
raise KeyError(key)
|
raise UnknownVariableError(
|
||||||
|
f"{key} is not defined in the environment, "
|
||||||
|
"secrets.yaml, or the environment_vars config"
|
||||||
|
)
|
||||||
out.append(FRIGATE_ENV_VARS[key])
|
out.append(FRIGATE_ENV_VARS[key])
|
||||||
i = ident_match.end() + 1
|
i = ident_match.end() + 1
|
||||||
continue
|
continue
|
||||||
@@ -94,10 +271,7 @@ EnvString = Annotated[str, AfterValidator(validate_env_string)]
|
|||||||
|
|
||||||
def validate_env_vars(v: dict[str, str], info: ValidationInfo) -> dict[str, str]:
|
def validate_env_vars(v: dict[str, str], info: ValidationInfo) -> dict[str, str]:
|
||||||
if isinstance(info.context, dict) and info.context.get("install", False):
|
if isinstance(info.context, dict) and info.context.get("install", False):
|
||||||
for k, val in v.items():
|
apply_config_env_vars(v)
|
||||||
os.environ[k] = val
|
|
||||||
if k.startswith("FRIGATE_"):
|
|
||||||
FRIGATE_ENV_VARS[k] = val
|
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"
|
|||||||
STREAM_TYPE_MAIN = "main"
|
STREAM_TYPE_MAIN = "main"
|
||||||
STREAM_TYPE_SUB = "sub"
|
STREAM_TYPE_SUB = "sub"
|
||||||
SUB_CACHE_TAG = "@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
|
# Attribute & Object constants
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from frigate.const import (
|
|||||||
ATTRIBUTE_LABEL_DISPLAY_MAP,
|
ATTRIBUTE_LABEL_DISPLAY_MAP,
|
||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
CLIPS_DIR,
|
CLIPS_DIR,
|
||||||
|
STREAM_TYPE_MAIN,
|
||||||
UPDATE_REVIEW_DESCRIPTION,
|
UPDATE_REVIEW_DESCRIPTION,
|
||||||
)
|
)
|
||||||
from frigate.data_processing.types import PostProcessDataEnum
|
from frigate.data_processing.types import PostProcessDataEnum
|
||||||
@@ -441,6 +442,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
)
|
)
|
||||||
.where((ts >= Recordings.start_time) & (ts <= Recordings.end_time))
|
.where((ts >= Recordings.start_time) & (ts <= Recordings.end_time))
|
||||||
.where(Recordings.camera == camera)
|
.where(Recordings.camera == camera)
|
||||||
|
.where(Recordings.stream_type == STREAM_TYPE_MAIN)
|
||||||
.order_by(Recordings.start_time.desc())
|
.order_by(Recordings.start_time.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""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()
|
||||||
@@ -714,7 +714,9 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
topic = str(raw_topic)
|
topic = str(raw_topic)
|
||||||
|
|
||||||
if topic.endswith(RecordingsDataTypeEnum.saved.value):
|
if topic.endswith(RecordingsDataTypeEnum.saved.value):
|
||||||
camera, recordings_available_through_timestamp, _ = payload
|
camera, _stream_type, recordings_available_through_timestamp, _ = (
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
|
||||||
self.recordings_available_through[camera] = (
|
self.recordings_available_through[camera] = (
|
||||||
recordings_available_through_timestamp
|
recordings_available_through_timestamp
|
||||||
|
|||||||
+29
-7
@@ -210,7 +210,11 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
# per-camera stop signal so a single maintainer can be torn down at
|
# per-camera stop signal so a single maintainer can be torn down at
|
||||||
# runtime (e.g. on camera removal) without stopping the whole process
|
# runtime (e.g. on camera removal) without stopping the whole process
|
||||||
self.camera_stop_event = threading.Event()
|
self.camera_stop_event = threading.Event()
|
||||||
self.detector = AudioTfl(stop_event, self.camera_config.audio.num_threads)
|
self.detector = AudioTfl(
|
||||||
|
stop_event,
|
||||||
|
self.camera_config.audio.num_threads,
|
||||||
|
self.camera_config.audio.labelmap,
|
||||||
|
)
|
||||||
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
|
self.shape = (int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE)),)
|
||||||
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
|
self.chunk_size = int(round(AUDIO_DURATION * AUDIO_SAMPLE_RATE * 2))
|
||||||
self.logger = logging.getLogger(f"audio.{self.camera_config.name}")
|
self.logger = logging.getLogger(f"audio.{self.camera_config.name}")
|
||||||
@@ -392,7 +396,10 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
|
|
||||||
while not self.stop_event.is_set() and not self.camera_stop_event.is_set():
|
while not self.stop_event.is_set() and not self.camera_stop_event.is_set():
|
||||||
# check if there is an updated config
|
# check if there is an updated config
|
||||||
self.config_subscriber.check_for_updates()
|
updated_topics = self.config_subscriber.check_for_updates()
|
||||||
|
|
||||||
|
if CameraConfigUpdateEnum.audio.name in updated_topics:
|
||||||
|
self.detector.update_labelmap(self.camera_config.audio.labelmap)
|
||||||
|
|
||||||
enabled = self.camera_config.enabled
|
enabled = self.camera_config.enabled
|
||||||
if enabled != self.was_enabled:
|
if enabled != self.was_enabled:
|
||||||
@@ -451,10 +458,17 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
|
|
||||||
|
|
||||||
class AudioTfl:
|
class AudioTfl:
|
||||||
def __init__(self, stop_event: threading.Event, num_threads: int = 2) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
stop_event: threading.Event,
|
||||||
|
num_threads: int = 2,
|
||||||
|
labelmap: dict[int, str] | None = None,
|
||||||
|
) -> None:
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.num_threads = num_threads
|
self.num_threads = num_threads
|
||||||
self.labels = load_labels("/audio-labelmap.txt", prefill=521)
|
self._default_labels = load_labels("/audio-labelmap.txt", prefill=521)
|
||||||
|
self.labels: dict[int, str] = {}
|
||||||
|
self.update_labelmap(labelmap or {})
|
||||||
# Suppress TFLite delegate creation messages that bypass Python logging
|
# Suppress TFLite delegate creation messages that bypass Python logging
|
||||||
with suppress_stderr_during("tflite_interpreter_init"):
|
with suppress_stderr_during("tflite_interpreter_init"):
|
||||||
self.interpreter = Interpreter(
|
self.interpreter = Interpreter(
|
||||||
@@ -466,6 +480,10 @@ class AudioTfl:
|
|||||||
self.tensor_input_details = self.interpreter.get_input_details()
|
self.tensor_input_details = self.interpreter.get_input_details()
|
||||||
self.tensor_output_details = self.interpreter.get_output_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:
|
def _detect_raw(self, tensor_input: np.ndarray) -> np.ndarray:
|
||||||
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
|
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
|
||||||
self.interpreter.invoke()
|
self.interpreter.invoke()
|
||||||
@@ -504,10 +522,14 @@ class AudioTfl:
|
|||||||
|
|
||||||
raw_detections = self._detect_raw(tensor_input)
|
raw_detections = self._detect_raw(tensor_input)
|
||||||
|
|
||||||
|
detected_labels: set[str] = set()
|
||||||
|
|
||||||
for d in raw_detections:
|
for d in raw_detections:
|
||||||
if d[1] < threshold:
|
if d[1] < threshold:
|
||||||
break
|
break
|
||||||
detections.append(
|
label = self.labels[int(d[0])]
|
||||||
(self.labels[int(d[0])], float(d[1]), (d[2], d[3], d[4], d[5]))
|
if label in detected_labels:
|
||||||
)
|
continue
|
||||||
|
detected_labels.add(label)
|
||||||
|
detections.append((label, float(d[1]), (d[2], d[3], d[4], d[5])))
|
||||||
return detections
|
return detections
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ class GeminiClient(GenAIClient):
|
|||||||
)
|
)
|
||||||
gemini_messages.append(
|
gemini_messages.append(
|
||||||
types.Content(
|
types.Content(
|
||||||
role="function",
|
role="user",
|
||||||
parts=[
|
parts=[
|
||||||
types.Part.from_function_response(
|
types.Part.from_function_response(
|
||||||
name=msg.get("name")
|
name=msg.get("name")
|
||||||
@@ -501,7 +501,7 @@ class GeminiClient(GenAIClient):
|
|||||||
)
|
)
|
||||||
gemini_messages.append(
|
gemini_messages.append(
|
||||||
types.Content(
|
types.Content(
|
||||||
role="function",
|
role="user",
|
||||||
parts=[
|
parts=[
|
||||||
types.Part.from_function_response(
|
types.Part.from_function_response(
|
||||||
name=msg.get("name")
|
name=msg.get("name")
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
|
|||||||
return cast(ModelSelect, query)
|
return cast(ModelSelect, query)
|
||||||
|
|
||||||
|
|
||||||
|
class NoRecordingsError(ValueError):
|
||||||
|
"""Raised when no recordings exist in the requested time range."""
|
||||||
|
|
||||||
|
|
||||||
class DebugReplaySource(ABC):
|
class DebugReplaySource(ABC):
|
||||||
"""Abstract source for a debug replay session.
|
"""Abstract source for a debug replay session.
|
||||||
|
|
||||||
@@ -187,7 +191,7 @@ class RecordingDebugReplaySource(DebugReplaySource):
|
|||||||
raise ValueError("End time must be after start time")
|
raise ValueError("End time must be after start time")
|
||||||
|
|
||||||
if not query_recordings(self._camera, self._start_ts, self._end_ts).count():
|
if not query_recordings(self._camera, self._start_ts, self._end_ts).count():
|
||||||
raise ValueError(
|
raise NoRecordingsError(
|
||||||
f"No recordings found for camera '{self._camera}' in the specified time range"
|
f"No recordings found for camera '{self._camera}' in the specified time range"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import numpy as np
|
|||||||
|
|
||||||
from frigate.config import CameraConfig
|
from frigate.config import CameraConfig
|
||||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_decode
|
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_decode
|
||||||
|
from frigate.util.ffmpeg import terminate_ffmpeg_stream
|
||||||
from frigate.util.services import auto_detect_hwaccel
|
from frigate.util.services import auto_detect_hwaccel
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -88,25 +89,6 @@ def _read_exact(stream: IO[bytes], size: int) -> bytes | None:
|
|||||||
return bytes(buf)
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
def _terminate(proc: sp.Popen[bytes]) -> None:
|
|
||||||
"""Stop an ffmpeg decode process promptly."""
|
|
||||||
# Close the read end first so a blocked ffmpeg write unblocks (ffmpeg then
|
|
||||||
# sees a broken pipe), then signal it. The resulting ffmpeg write error is
|
|
||||||
# harmless and goes to the captured stderr.
|
|
||||||
if proc.stdout is not None:
|
|
||||||
try:
|
|
||||||
proc.stdout.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if proc.poll() is None:
|
|
||||||
proc.terminate()
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
except sp.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
proc.wait()
|
|
||||||
|
|
||||||
|
|
||||||
KEYFRAME_MAX_GAP_SECONDS = 2.0
|
KEYFRAME_MAX_GAP_SECONDS = 2.0
|
||||||
|
|
||||||
|
|
||||||
@@ -222,7 +204,7 @@ def _run_vod_decode(
|
|||||||
count += 1
|
count += 1
|
||||||
yield frame
|
yield frame
|
||||||
finally:
|
finally:
|
||||||
_terminate(proc)
|
terminate_ffmpeg_stream(proc)
|
||||||
stderr_file.close()
|
stderr_file.close()
|
||||||
|
|
||||||
if count == 0 and software_retry and not should_stop():
|
if count == 0 and software_retry and not should_stop():
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class BaseLocalDetector(ObjectDetector):
|
|||||||
raw_detections = self.detect_raw(tensor_input) # type: ignore[attr-defined]
|
raw_detections = self.detect_raw(tensor_input) # type: ignore[attr-defined]
|
||||||
|
|
||||||
for d in raw_detections:
|
for d in raw_detections:
|
||||||
if int(d[0]) < 0 or int(d[0]) >= len(self.labels):
|
if int(d[0]) not in self.labels:
|
||||||
logger.warning(f"Raw Detect returned invalid label: {d}")
|
logger.warning(f"Raw Detect returned invalid label: {d}")
|
||||||
continue
|
continue
|
||||||
if d[1] < threshold:
|
if d[1] < threshold:
|
||||||
@@ -395,6 +395,9 @@ class RemoteObjectDetector:
|
|||||||
self.labels = labels
|
self.labels = labels
|
||||||
self.name = name
|
self.name = name
|
||||||
self.fps = EventsPerSecond()
|
self.fps = EventsPerSecond()
|
||||||
|
# class ids already warned about, so an incomplete labelmap logs once
|
||||||
|
# per id instead of once per frame
|
||||||
|
self.unnamed_class_ids: set[int] = set()
|
||||||
self.detection_queue = detection_queue
|
self.detection_queue = detection_queue
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.shm = UntrackedSharedMemory(name=self.name, create=False)
|
self.shm = UntrackedSharedMemory(name=self.name, create=False)
|
||||||
@@ -436,9 +439,21 @@ class RemoteObjectDetector:
|
|||||||
for d in self.out_np_shm:
|
for d in self.out_np_shm:
|
||||||
if d[1] < threshold:
|
if d[1] < threshold:
|
||||||
break
|
break
|
||||||
detections.append(
|
|
||||||
(self.labels[int(d[0])], float(d[1]), (d[2], d[3], d[4], d[5]))
|
class_id = int(d[0])
|
||||||
|
label = self.labels.get(class_id)
|
||||||
|
|
||||||
|
if label is None:
|
||||||
|
if class_id not in self.unnamed_class_ids:
|
||||||
|
self.unnamed_class_ids.add(class_id)
|
||||||
|
logger.warning(
|
||||||
|
"Detector returned class id %d for %s, which the labelmap does not name. Check that labelmap_path matches the model",
|
||||||
|
class_id,
|
||||||
|
self.name,
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
detections.append((label, float(d[1]), (d[2], d[3], d[4], d[5])))
|
||||||
self.fps.update()
|
self.fps.update()
|
||||||
return detections
|
return detections
|
||||||
|
|
||||||
|
|||||||
@@ -149,8 +149,12 @@ class RecordingCleanup(threading.Thread):
|
|||||||
detections_retain_mode: RetainModeEnum,
|
detections_retain_mode: RetainModeEnum,
|
||||||
config: CameraConfig,
|
config: CameraConfig,
|
||||||
reviews: list[Any],
|
reviews: list[Any],
|
||||||
) -> set[Path]:
|
) -> tuple[set[Path], list[tuple[float, float]]]:
|
||||||
"""Delete recordings for existing camera based on retention config."""
|
"""Delete recordings for one stream of an existing camera based on retention config.
|
||||||
|
|
||||||
|
Returns the directories to check for emptiness and the segments that
|
||||||
|
were kept, which the caller feeds to expire_camera_previews.
|
||||||
|
"""
|
||||||
# Get the timestamp for cutoff of retained days
|
# Get the timestamp for cutoff of retained days
|
||||||
|
|
||||||
# Get recordings to check for expiration
|
# Get recordings to check for expiration
|
||||||
@@ -257,9 +261,23 @@ class RecordingCleanup(threading.Thread):
|
|||||||
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
||||||
).execute()
|
).execute()
|
||||||
|
|
||||||
# previews follow main retention, so only the main pass expires them
|
return maybe_empty_dirs, kept_recordings
|
||||||
if stream_type != STREAM_TYPE_MAIN:
|
|
||||||
return maybe_empty_dirs
|
def expire_camera_previews(
|
||||||
|
self,
|
||||||
|
config: CameraConfig,
|
||||||
|
continuous_expire_date: float,
|
||||||
|
motion_expire_date: float,
|
||||||
|
kept_recordings: list[tuple[float, float]],
|
||||||
|
) -> set[Path]:
|
||||||
|
"""Delete previews that no longer have recordings on any stream.
|
||||||
|
|
||||||
|
Previews aren't recorded per stream, so the cutoffs must be the oldest
|
||||||
|
of the per stream values and kept_recordings must cover every stream,
|
||||||
|
sorted by start time. Otherwise a short main retention expires previews
|
||||||
|
the sub recordings still need.
|
||||||
|
"""
|
||||||
|
maybe_empty_dirs: set[Path] = set()
|
||||||
|
|
||||||
previews = (
|
previews = (
|
||||||
Previews.select(
|
Previews.select(
|
||||||
@@ -438,7 +456,7 @@ class RecordingCleanup(threading.Thread):
|
|||||||
.namedtuples()
|
.namedtuples()
|
||||||
)
|
)
|
||||||
|
|
||||||
maybe_empty_dirs |= self.expire_existing_camera_recordings(
|
main_dirs, main_kept = self.expire_existing_camera_recordings(
|
||||||
STREAM_TYPE_MAIN,
|
STREAM_TYPE_MAIN,
|
||||||
continuous_expire_date,
|
continuous_expire_date,
|
||||||
motion_expire_date,
|
motion_expire_date,
|
||||||
@@ -452,10 +470,11 @@ class RecordingCleanup(threading.Thread):
|
|||||||
config.record.detections.retain.days,
|
config.record.detections.retain.days,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
maybe_empty_dirs |= main_dirs
|
||||||
|
|
||||||
# runs even when sub recording is disabled so old rows still
|
# runs even when sub recording is disabled so old rows still
|
||||||
# expire
|
# expire
|
||||||
maybe_empty_dirs |= self.expire_existing_camera_recordings(
|
sub_dirs, sub_kept = self.expire_existing_camera_recordings(
|
||||||
STREAM_TYPE_SUB,
|
STREAM_TYPE_SUB,
|
||||||
sub_continuous_expire_date,
|
sub_continuous_expire_date,
|
||||||
sub_motion_expire_date,
|
sub_motion_expire_date,
|
||||||
@@ -469,6 +488,14 @@ class RecordingCleanup(threading.Thread):
|
|||||||
config.record.sub.detections.days,
|
config.record.sub.detections.days,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
maybe_empty_dirs |= sub_dirs
|
||||||
|
|
||||||
|
maybe_empty_dirs |= self.expire_camera_previews(
|
||||||
|
config,
|
||||||
|
min(continuous_expire_date, sub_continuous_expire_date),
|
||||||
|
min(motion_expire_date, sub_motion_expire_date),
|
||||||
|
sorted(main_kept + sub_kept),
|
||||||
|
)
|
||||||
logger.debug(f"End camera: {camera}.")
|
logger.debug(f"End camera: {camera}.")
|
||||||
|
|
||||||
logger.debug("End all cameras.")
|
logger.debug("End all cameras.")
|
||||||
|
|||||||
@@ -79,6 +79,54 @@ def parse_cache_segment_name(basename: str) -> tuple[str, str, str] | None:
|
|||||||
return (prefix, STREAM_TYPE_MAIN, date)
|
return (prefix, STREAM_TYPE_MAIN, date)
|
||||||
|
|
||||||
|
|
||||||
|
def format_segment_details(cache_path: str, segment_info: dict[str, Any]) -> str:
|
||||||
|
"""Comma separated facts about a segment, for discard warnings."""
|
||||||
|
details: list[str] = []
|
||||||
|
|
||||||
|
duration = segment_info.get("duration", -1)
|
||||||
|
|
||||||
|
if duration != -1:
|
||||||
|
details.append(f"duration: {duration:.2f}s")
|
||||||
|
|
||||||
|
try:
|
||||||
|
details.append(f"size: {os.path.getsize(cache_path) / 1024:.1f} KB")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
details.append(f"video: {segment_info.get('video_codec') or 'none'}")
|
||||||
|
|
||||||
|
if segment_info.get("has_audio"):
|
||||||
|
audio = segment_info.get("audio_codec") or "unknown"
|
||||||
|
rate = segment_info.get("audio_rate")
|
||||||
|
details.append(f"audio: {audio} {rate}Hz" if rate else f"audio: {audio}")
|
||||||
|
else:
|
||||||
|
details.append("audio: none")
|
||||||
|
|
||||||
|
return ", ".join(details)
|
||||||
|
|
||||||
|
|
||||||
|
def segment_path_time(cache_path: str) -> datetime.datetime | None:
|
||||||
|
"""Timestamp a segment's recording path is built from, or None if unparsable.
|
||||||
|
|
||||||
|
Recording paths carry one second of resolution, and so does ffmpeg's cache
|
||||||
|
segment template, which makes a cache file name unique per camera stream
|
||||||
|
and second. Resolved start times are not: a stream cutting segments faster
|
||||||
|
than once a second resolves consecutive segments into the same second, and
|
||||||
|
building the path from those collides on the unique path index.
|
||||||
|
"""
|
||||||
|
parsed = parse_cache_segment_name(Path(cache_path).stem)
|
||||||
|
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return datetime.datetime.strptime(parsed[2], CACHE_SEGMENT_FORMAT).astimezone(
|
||||||
|
datetime.UTC
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class SegmentInfo:
|
class SegmentInfo:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -241,49 +289,53 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
and not d.startswith("preview_")
|
and not d.startswith("preview_")
|
||||||
]
|
]
|
||||||
|
|
||||||
# publish newest cached segment per camera (including in use files)
|
# publish newest cached segment per camera stream (including in use files)
|
||||||
newest_cache_segments: dict[str, dict[str, Any]] = {}
|
newest_cache_segments: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
for cache in cache_files:
|
for cache in cache_files:
|
||||||
cache_path = os.path.join(CACHE_DIR, cache)
|
cache_path = os.path.join(CACHE_DIR, cache)
|
||||||
basename = os.path.splitext(cache)[0]
|
basename = os.path.splitext(cache)[0]
|
||||||
parsed = parse_cache_segment_name(basename)
|
parsed = parse_cache_segment_name(basename)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
if not self.unexpected_cache_files_logged:
|
if not self.unexpected_cache_files_logged:
|
||||||
logger.warning("Skipping unexpected files in cache")
|
logger.warning(f"Skipping unexpected files in cache, e.g. {cache}")
|
||||||
self.unexpected_cache_files_logged = True
|
self.unexpected_cache_files_logged = True
|
||||||
continue
|
continue
|
||||||
camera, stream_type, date = parsed
|
camera, stream_type, date = parsed
|
||||||
|
|
||||||
# this topic feeds main-stream health/sync consumers only
|
|
||||||
if stream_type == STREAM_TYPE_SUB:
|
|
||||||
continue
|
|
||||||
|
|
||||||
start_time = datetime.datetime.strptime(
|
start_time = datetime.datetime.strptime(
|
||||||
date, CACHE_SEGMENT_FORMAT
|
date, CACHE_SEGMENT_FORMAT
|
||||||
).astimezone(datetime.UTC)
|
).astimezone(datetime.UTC)
|
||||||
|
key = (camera, stream_type)
|
||||||
if (
|
if (
|
||||||
camera not in newest_cache_segments
|
key not in newest_cache_segments
|
||||||
or start_time > newest_cache_segments[camera]["start_time"]
|
or start_time > newest_cache_segments[key]["start_time"]
|
||||||
):
|
):
|
||||||
newest_cache_segments[camera] = {
|
newest_cache_segments[key] = {
|
||||||
"start_time": start_time,
|
"start_time": start_time,
|
||||||
"cache_path": cache_path,
|
"cache_path": cache_path,
|
||||||
}
|
}
|
||||||
|
|
||||||
for camera, newest in newest_cache_segments.items():
|
for (camera, stream_type), newest in newest_cache_segments.items():
|
||||||
self.recordings_publisher.publish(
|
self.recordings_publisher.publish(
|
||||||
(
|
(
|
||||||
camera,
|
camera,
|
||||||
|
stream_type,
|
||||||
newest["start_time"].timestamp(),
|
newest["start_time"].timestamp(),
|
||||||
newest["cache_path"],
|
newest["cache_path"],
|
||||||
),
|
),
|
||||||
RecordingsDataTypeEnum.latest.value,
|
RecordingsDataTypeEnum.latest.value,
|
||||||
)
|
)
|
||||||
# publish None for cameras with no cache files (but only if we know the camera exists)
|
# publish None for streams with no cache files (but only if we know the camera exists)
|
||||||
for camera_name in self.config.cameras:
|
for camera_name, camera_config in self.config.cameras.items():
|
||||||
if camera_name not in newest_cache_segments:
|
stream_types = [STREAM_TYPE_MAIN]
|
||||||
|
|
||||||
|
if camera_config.record.sub.enabled:
|
||||||
|
stream_types.append(STREAM_TYPE_SUB)
|
||||||
|
|
||||||
|
for stream_type in stream_types:
|
||||||
|
if (camera_name, stream_type) not in newest_cache_segments:
|
||||||
self.recordings_publisher.publish(
|
self.recordings_publisher.publish(
|
||||||
(camera_name, None, None),
|
(camera_name, stream_type, None, None),
|
||||||
RecordingsDataTypeEnum.latest.value,
|
RecordingsDataTypeEnum.latest.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -314,7 +366,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
parsed = parse_cache_segment_name(basename)
|
parsed = parse_cache_segment_name(basename)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
if not self.unexpected_cache_files_logged:
|
if not self.unexpected_cache_files_logged:
|
||||||
logger.warning("Skipping unexpected files in cache")
|
logger.warning(f"Skipping unexpected files in cache, e.g. {cache}")
|
||||||
self.unexpected_cache_files_logged = True
|
self.unexpected_cache_files_logged = True
|
||||||
continue
|
continue
|
||||||
camera, stream_type, date = parsed
|
camera, stream_type, date = parsed
|
||||||
@@ -447,6 +499,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
self.recordings_publisher.publish(
|
self.recordings_publisher.publish(
|
||||||
(
|
(
|
||||||
camera,
|
camera,
|
||||||
|
stream_type,
|
||||||
recordings[0]["start_time"].timestamp()
|
recordings[0]["start_time"].timestamp()
|
||||||
if camera_cfg and camera_cfg.record.enabled
|
if camera_cfg and camera_cfg.record.enabled
|
||||||
else None,
|
else None,
|
||||||
@@ -540,11 +593,11 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
if not segment_info.get("has_valid_video", False):
|
if not segment_info.get("has_valid_video", False):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Invalid or missing video stream in segment {cache_path}. Discarding."
|
f"Invalid or missing video stream in segment {cache_path} "
|
||||||
|
f"({format_segment_details(cache_path, segment_info)}). Discarding."
|
||||||
)
|
)
|
||||||
if stream_type == STREAM_TYPE_MAIN:
|
|
||||||
self.recordings_publisher.publish(
|
self.recordings_publisher.publish(
|
||||||
(camera, start_time.timestamp(), cache_path),
|
(camera, stream_type, start_time.timestamp(), cache_path),
|
||||||
RecordingsDataTypeEnum.invalid.value,
|
RecordingsDataTypeEnum.invalid.value,
|
||||||
)
|
)
|
||||||
self.drop_segment(cache_path)
|
self.drop_segment(cache_path)
|
||||||
@@ -583,19 +636,20 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
if duration == -1:
|
if duration == -1:
|
||||||
logger.warning(f"Failed to probe corrupt segment {cache_path}")
|
logger.warning(f"Failed to probe corrupt segment {cache_path}")
|
||||||
|
|
||||||
logger.warning(f"Discarding a corrupt recording segment: {cache_path}")
|
logger.warning(
|
||||||
if stream_type == STREAM_TYPE_MAIN:
|
f"Discarding a corrupt recording segment: {cache_path} "
|
||||||
|
f"({format_segment_details(cache_path, segment_info)})"
|
||||||
|
)
|
||||||
self.recordings_publisher.publish(
|
self.recordings_publisher.publish(
|
||||||
(camera, start_time.timestamp(), cache_path),
|
(camera, stream_type, start_time.timestamp(), cache_path),
|
||||||
RecordingsDataTypeEnum.invalid.value,
|
RecordingsDataTypeEnum.invalid.value,
|
||||||
)
|
)
|
||||||
self.drop_segment(cache_path)
|
self.drop_segment(cache_path)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# this segment has a valid duration and has video data, so publish an update
|
# this segment has a valid duration and has video data, so publish an update
|
||||||
if stream_type == STREAM_TYPE_MAIN:
|
|
||||||
self.recordings_publisher.publish(
|
self.recordings_publisher.publish(
|
||||||
(camera, start_time.timestamp(), cache_path),
|
(camera, stream_type, start_time.timestamp(), cache_path),
|
||||||
RecordingsDataTypeEnum.valid.value,
|
RecordingsDataTypeEnum.valid.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -863,18 +917,20 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
video_codec: str | None = None,
|
video_codec: str | None = None,
|
||||||
keyframes: list[int] | None = None,
|
keyframes: list[int] | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
# directory will be in utc due to start_time being in utc
|
path_time = segment_path_time(cache_path) or start_time
|
||||||
|
|
||||||
|
# directory will be in utc due to path_time being in utc
|
||||||
# sub segments get a tagged directory to avoid filename collisions
|
# sub segments get a tagged directory to avoid filename collisions
|
||||||
directory = os.path.join(
|
directory = os.path.join(
|
||||||
RECORD_DIR,
|
RECORD_DIR,
|
||||||
start_time.strftime("%Y-%m-%d/%H"),
|
path_time.strftime("%Y-%m-%d/%H"),
|
||||||
camera if stream_type == STREAM_TYPE_MAIN else f"{camera}{SUB_CACHE_TAG}",
|
camera if stream_type == STREAM_TYPE_MAIN else f"{camera}{SUB_CACHE_TAG}",
|
||||||
)
|
)
|
||||||
|
|
||||||
os.makedirs(directory, exist_ok=True)
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
|
||||||
# file will be in utc due to start_time being in utc
|
# file will be in utc due to path_time being in utc
|
||||||
file_name = f"{start_time.strftime('%M.%S.mp4')}"
|
file_name = f"{path_time.strftime('%M.%S.mp4')}"
|
||||||
file_path = os.path.join(directory, file_name)
|
file_path = os.path.join(directory, file_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -946,10 +1002,9 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
Recordings.video_codec.name: video_codec,
|
Recordings.video_codec.name: video_codec,
|
||||||
Recordings.keyframes.name: keyframes,
|
Recordings.keyframes.name: keyframes,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(f"Unable to store recording segment {cache_path}")
|
logger.exception(f"Unable to store recording segment {cache_path}")
|
||||||
Path(cache_path).unlink(missing_ok=True)
|
Path(cache_path).unlink(missing_ok=True)
|
||||||
logger.error(e)
|
|
||||||
|
|
||||||
# clear end_time cache
|
# clear end_time cache
|
||||||
self.end_time_cache.pop(cache_path, None)
|
self.end_time_cache.pop(cache_path, None)
|
||||||
|
|||||||
+120
-29
@@ -9,6 +9,10 @@ from pathlib import Path
|
|||||||
from peewee import SQL, Case, fn
|
from peewee import SQL, Case, fn
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.camera.updater import (
|
||||||
|
CameraConfigUpdateEnum,
|
||||||
|
CameraConfigUpdateSubscriber,
|
||||||
|
)
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
RECORD_DIR,
|
RECORD_DIR,
|
||||||
REPLAY_CAMERA_PREFIX,
|
REPLAY_CAMERA_PREFIX,
|
||||||
@@ -24,6 +28,7 @@ bandwidth_equation = Recordings.segment_size / (
|
|||||||
)
|
)
|
||||||
|
|
||||||
MAX_CALCULATED_BANDWIDTH = 10000 # 10Gb/hr
|
MAX_CALCULATED_BANDWIDTH = 10000 # 10Gb/hr
|
||||||
|
BANDWIDTH_SAMPLE_TARGET = 50
|
||||||
|
|
||||||
|
|
||||||
class StorageMaintainer(threading.Thread):
|
class StorageMaintainer(threading.Thread):
|
||||||
@@ -34,6 +39,41 @@ class StorageMaintainer(threading.Thread):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.camera_storage_stats: dict[str, dict] = {}
|
self.camera_storage_stats: dict[str, dict] = {}
|
||||||
|
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||||
|
self.config,
|
||||||
|
self.config.cameras,
|
||||||
|
[CameraConfigUpdateEnum.record],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _recording_stream_types(self, camera: str) -> tuple[str, ...]:
|
||||||
|
"""Return the stream types the camera is currently recording."""
|
||||||
|
camera_config = self.config.cameras.get(camera)
|
||||||
|
|
||||||
|
if camera_config is None or not camera_config.record.enabled:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
if camera_config.record.sub.enabled:
|
||||||
|
return (STREAM_TYPE_MAIN, STREAM_TYPE_SUB)
|
||||||
|
|
||||||
|
return (STREAM_TYPE_MAIN,)
|
||||||
|
|
||||||
|
def expected_hourly_bandwidth(self) -> float:
|
||||||
|
"""Return the MB/hr the cameras are expected to write.
|
||||||
|
|
||||||
|
Only the streams a camera currently records are counted, so toggling
|
||||||
|
recording or sub stream recording is reflected without waiting for the
|
||||||
|
existing segments of a stopped stream to expire.
|
||||||
|
"""
|
||||||
|
total = 0.0
|
||||||
|
|
||||||
|
for camera, stats in self.camera_storage_stats.items():
|
||||||
|
stream_bandwidths = stats.get("bandwidth_by_stream", {})
|
||||||
|
total += sum(
|
||||||
|
stream_bandwidths.get(stream_type, 0)
|
||||||
|
for stream_type in self._recording_stream_types(camera)
|
||||||
|
)
|
||||||
|
|
||||||
|
return round(total, 2)
|
||||||
|
|
||||||
def _recent_stream_bandwidth(
|
def _recent_stream_bandwidth(
|
||||||
self, camera: str, stream_type: str, window: int
|
self, camera: str, stream_type: str, window: int
|
||||||
@@ -64,6 +104,32 @@ class StorageMaintainer(threading.Thread):
|
|||||||
avg: float | None = Recordings.select(fn.AVG(SQL("bw"))).from_(recent).scalar()
|
avg: float | None = Recordings.select(fn.AVG(SQL("bw"))).from_(recent).scalar()
|
||||||
return avg
|
return avg
|
||||||
|
|
||||||
|
def _stream_sample_count(self, camera: str, stream_type: str) -> int:
|
||||||
|
"""Count a stream's non-zero segments, stopping at the sample target."""
|
||||||
|
count: int = (
|
||||||
|
Recordings.select(Recordings.id)
|
||||||
|
.where(
|
||||||
|
Recordings.camera == camera,
|
||||||
|
Recordings.stream_type == stream_type,
|
||||||
|
Recordings.segment_size > 0,
|
||||||
|
)
|
||||||
|
.limit(BANDWIDTH_SAMPLE_TARGET)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def _needs_refresh(self, camera: str) -> bool:
|
||||||
|
"""Return whether a stream the camera records still lacks samples.
|
||||||
|
|
||||||
|
Counted per stream rather than per camera: a stream that starts
|
||||||
|
recording later has no samples of its own yet, and a camera-wide count
|
||||||
|
would report it settled on the strength of another stream's history.
|
||||||
|
"""
|
||||||
|
return any(
|
||||||
|
self._stream_sample_count(camera, stream_type) < BANDWIDTH_SAMPLE_TARGET
|
||||||
|
for stream_type in self._recording_stream_types(camera)
|
||||||
|
)
|
||||||
|
|
||||||
def calculate_camera_bandwidth(self) -> None:
|
def calculate_camera_bandwidth(self) -> None:
|
||||||
"""Calculate an average MB/hr for each camera."""
|
"""Calculate an average MB/hr for each camera."""
|
||||||
for camera in self.config.cameras.keys():
|
for camera in self.config.cameras.keys():
|
||||||
@@ -71,45 +137,44 @@ class StorageMaintainer(threading.Thread):
|
|||||||
if camera.startswith(REPLAY_CAMERA_PREFIX):
|
if camera.startswith(REPLAY_CAMERA_PREFIX):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# cameras with < 50 segments should be refreshed to keep size accurate
|
if not self.camera_storage_stats.get(camera, {}).get("needs_refresh", True):
|
||||||
# when few segments are available
|
continue
|
||||||
if self.camera_storage_stats.get(camera, {}).get("needs_refresh", True):
|
|
||||||
self.camera_storage_stats[camera] = {
|
|
||||||
"needs_refresh": (
|
|
||||||
Recordings.select(Recordings.id)
|
|
||||||
.where(Recordings.camera == camera, Recordings.segment_size > 0)
|
|
||||||
.limit(50)
|
|
||||||
.count()
|
|
||||||
< 50
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
# calculate MB/hr from the last 100 segments of each stream
|
# calculate MB/hr from the last 100 segments of each stream
|
||||||
# type and sum the rates; mixing streams would average small
|
# type and sum the rates; mixing streams would average small
|
||||||
# sub segments against large main segments and underestimate
|
# sub segments against large main segments and underestimate
|
||||||
# the true write rate
|
# the true write rate
|
||||||
bandwidth = 0.0
|
bandwidth_by_stream: dict[str, float] = {}
|
||||||
for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB):
|
for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB):
|
||||||
avg_bw = self._recent_stream_bandwidth(camera, stream_type, 100)
|
avg_bw = self._recent_stream_bandwidth(camera, stream_type, 100)
|
||||||
if avg_bw is None:
|
if avg_bw is None:
|
||||||
# the recent window can be all zero-size ingest
|
# the recent window can be all zero-size ingest
|
||||||
# glitches; look further back before concluding
|
# glitches; look further back before concluding
|
||||||
# the stream writes nothing
|
# the stream writes nothing
|
||||||
avg_bw = self._recent_stream_bandwidth(
|
avg_bw = self._recent_stream_bandwidth(camera, stream_type, 1000)
|
||||||
camera, stream_type, 1000
|
|
||||||
)
|
|
||||||
if avg_bw is not None:
|
if avg_bw is not None:
|
||||||
bandwidth += round(avg_bw * 3600, 2)
|
bandwidth_by_stream[stream_type] = round(avg_bw * 3600, 2)
|
||||||
|
|
||||||
bandwidth = round(bandwidth, 2)
|
bandwidth = round(sum(bandwidth_by_stream.values()), 2)
|
||||||
|
|
||||||
if bandwidth > MAX_CALCULATED_BANDWIDTH:
|
if bandwidth > MAX_CALCULATED_BANDWIDTH:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"{camera} has a bandwidth of {bandwidth} MB/hr which exceeds the expected maximum. This typically indicates an issue with the cameras recordings."
|
f"{camera} has a bandwidth of {bandwidth} MB/hr which exceeds the expected maximum. This typically indicates an issue with the cameras recordings."
|
||||||
)
|
)
|
||||||
|
# scale each stream so the per stream values still sum to
|
||||||
|
# the clamped total the UI displays alongside them
|
||||||
|
scale = MAX_CALCULATED_BANDWIDTH / bandwidth
|
||||||
|
bandwidth_by_stream = {
|
||||||
|
stream_type: round(value * scale, 2)
|
||||||
|
for stream_type, value in bandwidth_by_stream.items()
|
||||||
|
}
|
||||||
bandwidth = MAX_CALCULATED_BANDWIDTH
|
bandwidth = MAX_CALCULATED_BANDWIDTH
|
||||||
|
|
||||||
self.camera_storage_stats[camera]["bandwidth"] = bandwidth
|
self.camera_storage_stats[camera] = {
|
||||||
|
"needs_refresh": self._needs_refresh(camera),
|
||||||
|
"bandwidth": bandwidth,
|
||||||
|
"bandwidth_by_stream": bandwidth_by_stream,
|
||||||
|
}
|
||||||
logger.debug(f"{camera} has a bandwidth of {bandwidth} MiB/hr.")
|
logger.debug(f"{camera} has a bandwidth of {bandwidth} MiB/hr.")
|
||||||
|
|
||||||
def calculate_camera_usages(self) -> dict[str, dict]:
|
def calculate_camera_usages(self) -> dict[str, dict]:
|
||||||
@@ -121,20 +186,42 @@ class StorageMaintainer(threading.Thread):
|
|||||||
if camera.startswith(REPLAY_CAMERA_PREFIX):
|
if camera.startswith(REPLAY_CAMERA_PREFIX):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
camera_storage = (
|
stream_usages = {
|
||||||
Recordings.select(fn.SUM(Recordings.segment_size))
|
row["stream_type"]: row["usage"] or 0
|
||||||
|
for row in (
|
||||||
|
Recordings.select(
|
||||||
|
Recordings.stream_type,
|
||||||
|
fn.SUM(Recordings.segment_size).alias("usage"),
|
||||||
|
)
|
||||||
.where(Recordings.camera == camera, Recordings.segment_size != 0)
|
.where(Recordings.camera == camera, Recordings.segment_size != 0)
|
||||||
.scalar()
|
.group_by(Recordings.stream_type)
|
||||||
|
.dicts()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stream_bandwidths = self.camera_storage_stats.get(camera, {}).get(
|
||||||
|
"bandwidth_by_stream", {}
|
||||||
)
|
)
|
||||||
|
|
||||||
camera_key = (
|
camera_key = (
|
||||||
getattr(self.config.cameras[camera], "friendly_name", None) or camera
|
getattr(self.config.cameras[camera], "friendly_name", None) or camera
|
||||||
)
|
)
|
||||||
usages[camera_key] = {
|
usages[camera_key] = {
|
||||||
"usage": camera_storage,
|
"usage": sum(stream_usages.values()),
|
||||||
"bandwidth": self.camera_storage_stats.get(camera, {}).get(
|
"bandwidth": self.camera_storage_stats.get(camera, {}).get(
|
||||||
"bandwidth", 0
|
"bandwidth", 0
|
||||||
),
|
),
|
||||||
|
# only streams with segments on disk are reported, so a camera
|
||||||
|
# keeps its sub entry until sub retention expires those segments.
|
||||||
|
# bandwidth is null rather than 0 when the cache holds no sample
|
||||||
|
# for the stream, since 0 would claim it writes nothing
|
||||||
|
"streams": {
|
||||||
|
stream_type: {
|
||||||
|
"usage": stream_usages[stream_type],
|
||||||
|
"bandwidth": stream_bandwidths.get(stream_type),
|
||||||
|
}
|
||||||
|
for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB)
|
||||||
|
if stream_usages.get(stream_type)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return usages
|
return usages
|
||||||
@@ -143,9 +230,7 @@ class StorageMaintainer(threading.Thread):
|
|||||||
"""Return if storage needs cleanup."""
|
"""Return if storage needs cleanup."""
|
||||||
# currently runs cleanup if less than 1 hour of space is left
|
# currently runs cleanup if less than 1 hour of space is left
|
||||||
# disk_usage should not spin up disks
|
# disk_usage should not spin up disks
|
||||||
hourly_bandwidth = sum(
|
hourly_bandwidth = self.expected_hourly_bandwidth()
|
||||||
[b["bandwidth"] for b in self.camera_storage_stats.values()]
|
|
||||||
)
|
|
||||||
remaining_storage = round(shutil.disk_usage(RECORD_DIR).free / pow(2, 20), 1)
|
remaining_storage = round(shutil.disk_usage(RECORD_DIR).free / pow(2, 20), 1)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Storage cleanup check: {hourly_bandwidth} hourly with remaining storage: {remaining_storage}."
|
f"Storage cleanup check: {hourly_bandwidth} hourly with remaining storage: {remaining_storage}."
|
||||||
@@ -156,9 +241,7 @@ class StorageMaintainer(threading.Thread):
|
|||||||
"""Remove oldest hour of recordings."""
|
"""Remove oldest hour of recordings."""
|
||||||
logger.debug("Starting storage cleanup.")
|
logger.debug("Starting storage cleanup.")
|
||||||
deleted_segments_size = 0
|
deleted_segments_size = 0
|
||||||
hourly_bandwidth = sum(
|
hourly_bandwidth = self.expected_hourly_bandwidth()
|
||||||
[b["bandwidth"] for b in self.camera_storage_stats.values()]
|
|
||||||
)
|
|
||||||
|
|
||||||
recordings = (
|
recordings = (
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
@@ -318,10 +401,17 @@ class StorageMaintainer(threading.Thread):
|
|||||||
"""Check every 5 minutes if storage needs to be cleaned up."""
|
"""Check every 5 minutes if storage needs to be cleaned up."""
|
||||||
if self.config.safe_mode:
|
if self.config.safe_mode:
|
||||||
logger.info("Safe mode enabled, skipping storage maintenance")
|
logger.info("Safe mode enabled, skipping storage maintenance")
|
||||||
|
self.config_subscriber.stop()
|
||||||
return
|
return
|
||||||
|
|
||||||
self.calculate_camera_bandwidth()
|
self.calculate_camera_bandwidth()
|
||||||
while not self.stop_event.wait(300):
|
while not self.stop_event.wait(300):
|
||||||
|
updated_topics = self.config_subscriber.check_for_updates()
|
||||||
|
|
||||||
|
for camera in updated_topics.get(CameraConfigUpdateEnum.record.name, []):
|
||||||
|
if camera in self.camera_storage_stats:
|
||||||
|
self.camera_storage_stats[camera]["needs_refresh"] = True
|
||||||
|
|
||||||
if not self.camera_storage_stats or True in [
|
if not self.camera_storage_stats or True in [
|
||||||
r["needs_refresh"] for r in self.camera_storage_stats.values()
|
r["needs_refresh"] for r in self.camera_storage_stats.values()
|
||||||
]:
|
]:
|
||||||
@@ -334,4 +424,5 @@ class StorageMaintainer(threading.Thread):
|
|||||||
)
|
)
|
||||||
self.reduce_storage_consumption()
|
self.reduce_storage_consumption()
|
||||||
|
|
||||||
|
self.config_subscriber.stop()
|
||||||
logger.info("Exiting storage maintainer...")
|
logger.info("Exiting storage maintainer...")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from frigate.jobs.debug_replay import NoRecordingsError
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
@@ -66,6 +67,32 @@ class TestDebugReplayAPI(BaseTestHttp):
|
|||||||
# (CodeQL: information exposure through an exception).
|
# (CodeQL: information exposure through an exception).
|
||||||
self.assertEqual(body["message"], "Invalid debug replay parameters")
|
self.assertEqual(body["message"], "Invalid debug replay parameters")
|
||||||
|
|
||||||
|
def test_start_returns_404_when_no_recordings(self):
|
||||||
|
with patch(
|
||||||
|
"frigate.api.debug_replay.start_debug_replay_job",
|
||||||
|
side_effect=NoRecordingsError(
|
||||||
|
"No recordings found for camera 'front' in the specified time range"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
resp = client.post(
|
||||||
|
"/debug_replay/start",
|
||||||
|
json={
|
||||||
|
"camera": "front",
|
||||||
|
"start_time": 100,
|
||||||
|
"end_time": 200,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 404)
|
||||||
|
body = resp.json()
|
||||||
|
self.assertFalse(body["success"])
|
||||||
|
# Message is hard-coded so we don't echo exception text back to clients
|
||||||
|
# (CodeQL: information exposure through an exception).
|
||||||
|
self.assertEqual(
|
||||||
|
body["message"], "No recordings found in the selected time range"
|
||||||
|
)
|
||||||
|
|
||||||
def test_start_returns_409_when_session_already_active(self):
|
def test_start_returns_409_when_session_already_active(self):
|
||||||
with patch(
|
with patch(
|
||||||
"frigate.api.debug_replay.start_debug_replay_job",
|
"frigate.api.debug_replay.start_debug_replay_job",
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Tests for the audio labels API."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from frigate.models import Event
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpAudioLabels(BaseTestHttp):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp([Event])
|
||||||
|
|
||||||
|
def _labels(self, config: dict | None = None) -> dict[str, str]:
|
||||||
|
if config:
|
||||||
|
self.minimal_config.update(config)
|
||||||
|
|
||||||
|
app = self.create_app()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"frigate.api.app.load_labels", return_value={0: "speech", 1: "bark"}
|
||||||
|
):
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
response = client.get("/audio_labels")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def test_the_default_labels_are_returned(self):
|
||||||
|
self.assertEqual(self._labels(), {"0": "speech", "1": "bark"})
|
||||||
|
|
||||||
|
def test_a_global_labelmap_override_is_offered(self):
|
||||||
|
# grouping several classes under one label makes that label selectable
|
||||||
|
labels = self._labels({"audio": {"labelmap": {0: "noise", 1: "noise"}}})
|
||||||
|
|
||||||
|
self.assertEqual(set(labels.values()), {"noise"})
|
||||||
|
|
||||||
|
def test_a_camera_labelmap_override_is_offered(self):
|
||||||
|
labels = self._labels(
|
||||||
|
{
|
||||||
|
"cameras": {
|
||||||
|
"front_door": {
|
||||||
|
**self.minimal_config["cameras"]["front_door"],
|
||||||
|
"audio": {"labelmap": {1: "dogs"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(labels["1"], "dogs")
|
||||||
|
self.assertEqual(labels["0"], "speech")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Tests for authentication endpoints."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from frigate.api.auth import hash_password
|
||||||
|
from frigate.const import JWT_SECRET_ENV_VAR
|
||||||
|
from frigate.models import User
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
||||||
|
class TestHttpAuth(BaseTestHttp):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp([User])
|
||||||
|
self.app = super().create_app()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
User.delete().execute()
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
|
def test_login_unknown_user_logs_warning(self):
|
||||||
|
with self.assertLogs("frigate.api.auth", level="WARNING") as logs:
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/login", json={"user": "ghost", "password": "irrelevant"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert any("Login failed" in m and "ghost" in m for m in logs.output)
|
||||||
|
|
||||||
|
def test_login_bad_password_logs_warning(self):
|
||||||
|
password_hash = hash_password("correct-horse-battery", iterations=1000)
|
||||||
|
User.insert(
|
||||||
|
username="admin",
|
||||||
|
password_hash=password_hash,
|
||||||
|
role="admin",
|
||||||
|
notification_tokens=[],
|
||||||
|
).execute()
|
||||||
|
with self.assertLogs("frigate.api.auth", level="WARNING") as logs:
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/login", json={"user": "admin", "password": "wrong"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert any("Login failed" in m and "admin" in m for m in logs.output)
|
||||||
@@ -390,7 +390,7 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
|
|||||||
intent and forward the request to go2rtc instead of short-circuiting with 400."""
|
intent and forward the request to go2rtc instead of short-circuiting with 400."""
|
||||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||||
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
|
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
|
||||||
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
|
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
|
||||||
with patch(
|
with patch(
|
||||||
"frigate.api.camera.requests.put", return_value=mock_response
|
"frigate.api.camera.requests.put", return_value=mock_response
|
||||||
) as mock_put:
|
) as mock_put:
|
||||||
@@ -403,6 +403,20 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
|
|||||||
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
|
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
|
||||||
assert forwarded_src == "exec:/tmp/something"
|
assert forwarded_src == "exec:/tmp/something"
|
||||||
|
|
||||||
|
def test_add_stream_ignores_override_written_after_import(self):
|
||||||
|
"""The override is read once at import. A value written into os.environ
|
||||||
|
later, which is what the config's environment_vars block does, must not
|
||||||
|
unlock restricted sources."""
|
||||||
|
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||||
|
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
|
||||||
|
with patch("frigate.api.camera.requests.put") as mock_put:
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.put("/go2rtc/streams/legit?src=exec:/tmp/something")
|
||||||
|
# A live go2rtc would also answer 400, so assert on the forward.
|
||||||
|
mock_put.assert_not_called()
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json().get("success") is False
|
||||||
|
|
||||||
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
|
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
|
||||||
"""limited_user cannot access a stream alias that belongs to a camera they
|
"""limited_user cannot access a stream alias that belongs to a camera they
|
||||||
are not allowed to see."""
|
are not allowed to see."""
|
||||||
@@ -440,3 +454,68 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
|
|||||||
f"limited_user should be denied on alias back_door_main; "
|
f"limited_user should be denied on alias back_door_main; "
|
||||||
f"got {resp.status_code}"
|
f"got {resp.status_code}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewSummaryAccess(BaseTestHttp):
|
||||||
|
"""Tests for POST /review/summarize/start/{start_ts}/end/{end_ts}.
|
||||||
|
|
||||||
|
The summary correlates each flagged event with overlapping activity on
|
||||||
|
other cameras, so it is gated on full camera access rather than scoped to
|
||||||
|
the caller's cameras. These tests pin that decision so the dependency is
|
||||||
|
not loosened without first scoping the query.
|
||||||
|
|
||||||
|
GenAI is not configured in unit tests, so an authorized request returns 400
|
||||||
|
while an unauthorized one is rejected with 403 before the handler runs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp([Event, ReviewSegment, Recordings])
|
||||||
|
self.minimal_config = _MULTI_CAMERA_CONFIG
|
||||||
|
self.app = super().create_app()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.app.dependency_overrides.clear()
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
|
def _summarize(self, allowed_cameras: list[str]):
|
||||||
|
async def mock_cameras(request: Request):
|
||||||
|
return allowed_cameras
|
||||||
|
|
||||||
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
return client.post("/review/summarize/start/0/end/9999999999")
|
||||||
|
|
||||||
|
def _assert_allowed(self, resp):
|
||||||
|
assert resp.status_code not in (401, 403), (
|
||||||
|
f"Caller should not be blocked; got {resp.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_partial_camera_access_blocked(self):
|
||||||
|
assert self._summarize(["front_door"]).status_code == 403
|
||||||
|
|
||||||
|
def test_no_camera_access_blocked(self):
|
||||||
|
assert self._summarize([]).status_code == 403
|
||||||
|
|
||||||
|
def test_full_camera_access_allowed(self):
|
||||||
|
# Covers admin and viewer, which always resolve to every camera, and a
|
||||||
|
# custom role whose list happens to name them all.
|
||||||
|
self._assert_allowed(self._summarize(["front_door", "back_door"]))
|
||||||
|
|
||||||
|
def _summarize_as_role(self, role: str):
|
||||||
|
"""Summarize using the real role to allowed-cameras resolution."""
|
||||||
|
self.app.dependency_overrides.pop(get_allowed_cameras_for_filter, None)
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
return client.post(
|
||||||
|
"/review/summarize/start/0/end/9999999999",
|
||||||
|
headers={"remote-user": "test", "remote-role": role},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_viewer_role_allowed(self):
|
||||||
|
# viewer is never camera restricted, so it resolves to every camera.
|
||||||
|
self._assert_allowed(self._summarize_as_role("viewer"))
|
||||||
|
|
||||||
|
def test_admin_role_allowed(self):
|
||||||
|
self._assert_allowed(self._summarize_as_role("admin"))
|
||||||
|
|
||||||
|
def test_restricted_role_blocked(self):
|
||||||
|
assert self._summarize_as_role("limited_user").status_code == 403
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Tests for GET /classification/attributes."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from frigate.api.auth import get_allowed_cameras_for_filter
|
||||||
|
from frigate.const import CLIPS_DIR
|
||||||
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
# "limited_user" only reaches front_door, so it never sees the values that were
|
||||||
|
# recorded on back_door.
|
||||||
|
_CONFIG = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"auth": {"roles": {"limited_user": ["front_door"]}},
|
||||||
|
"classification": {
|
||||||
|
"custom": {
|
||||||
|
"delivery_service": {
|
||||||
|
"enabled": True,
|
||||||
|
"object_config": {
|
||||||
|
"objects": ["car"],
|
||||||
|
"classification_type": "attribute",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cameras": {
|
||||||
|
"front_door": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
|
||||||
|
},
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||||
|
},
|
||||||
|
"back_door": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}]
|
||||||
|
},
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassificationAttributesAccess(BaseTestHttp):
|
||||||
|
"""The attribute list is read from the training dataset on disk, which holds
|
||||||
|
every label a model can emit regardless of which camera recorded it. Callers
|
||||||
|
without full camera access are cut back to the values on their own cameras,
|
||||||
|
so these tests pin that scoping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp([Event, ReviewSegment, Recordings])
|
||||||
|
self.minimal_config = _CONFIG
|
||||||
|
self.app = super().create_app()
|
||||||
|
self.model_dir = os.path.join(CLIPS_DIR, "delivery_service")
|
||||||
|
|
||||||
|
for category in ("DHL", "Amazon", "Hermes", "none"):
|
||||||
|
os.makedirs(
|
||||||
|
os.path.join(self.model_dir, "dataset", category), exist_ok=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.model_dir, ignore_errors=True)
|
||||||
|
self.app.dependency_overrides.clear()
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
|
def _insert_event(self, event_id: str, camera: str, attribute: str | None):
|
||||||
|
data = {"type": "object", "score": 0.9}
|
||||||
|
|
||||||
|
if attribute is not None:
|
||||||
|
data["delivery_service"] = attribute
|
||||||
|
|
||||||
|
Event.insert(
|
||||||
|
id=event_id,
|
||||||
|
label="car",
|
||||||
|
camera=camera,
|
||||||
|
start_time=100,
|
||||||
|
end_time=200,
|
||||||
|
top_score=0.9,
|
||||||
|
score=0.9,
|
||||||
|
false_positive=False,
|
||||||
|
zones=[],
|
||||||
|
thumbnail="",
|
||||||
|
has_clip=True,
|
||||||
|
has_snapshot=True,
|
||||||
|
region=[],
|
||||||
|
box=[],
|
||||||
|
area=0,
|
||||||
|
retain_indefinitely=False,
|
||||||
|
ratio=1.0,
|
||||||
|
plus_id=None,
|
||||||
|
model_hash="",
|
||||||
|
detector_type="cpu",
|
||||||
|
model_type="ssd",
|
||||||
|
data=data,
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
def _get(self, role: str, **params):
|
||||||
|
# the base class resolves every camera by default, so drop the override
|
||||||
|
# to exercise the real role to allowed-cameras resolution
|
||||||
|
self.app.dependency_overrides.pop(get_allowed_cameras_for_filter, None)
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
return client.get(
|
||||||
|
"/classification/attributes",
|
||||||
|
params=params,
|
||||||
|
headers={"remote-user": "test", "remote-role": role},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _insert_split_events(self):
|
||||||
|
self._insert_event("front", "front_door", "DHL")
|
||||||
|
self._insert_event("back", "back_door", "Amazon")
|
||||||
|
|
||||||
|
def test_admin_gets_every_trained_label(self):
|
||||||
|
self._insert_split_events()
|
||||||
|
assert self._get("admin").json() == ["Amazon", "DHL", "Hermes"]
|
||||||
|
|
||||||
|
def test_viewer_gets_every_trained_label(self):
|
||||||
|
self._insert_split_events()
|
||||||
|
assert self._get("viewer").json() == ["Amazon", "DHL", "Hermes"]
|
||||||
|
|
||||||
|
def test_restricted_role_only_gets_its_own_cameras(self):
|
||||||
|
self._insert_split_events()
|
||||||
|
assert self._get("limited_user").json() == ["DHL"]
|
||||||
|
|
||||||
|
def test_restricted_role_grouped_by_model(self):
|
||||||
|
self._insert_split_events()
|
||||||
|
assert self._get("limited_user", group_by_model="true").json() == {
|
||||||
|
"delivery_service": ["DHL"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_restricted_role_with_no_recorded_values(self):
|
||||||
|
self._insert_event("back", "back_door", "Amazon")
|
||||||
|
assert self._get("limited_user").json() == []
|
||||||
|
assert self._get("limited_user", group_by_model="true").json() == {}
|
||||||
|
|
||||||
|
def test_restricted_role_ignores_events_without_the_attribute(self):
|
||||||
|
self._insert_event("front", "front_door", None)
|
||||||
|
assert self._get("limited_user").json() == []
|
||||||
|
|
||||||
|
def test_restricted_role_with_a_dotted_model_name(self):
|
||||||
|
# model names are unrestricted config keys, and an unquoted "." in the
|
||||||
|
# json path would be read as a nested lookup and match nothing
|
||||||
|
self.app.frigate_config.classification.custom["delivery.service"] = (
|
||||||
|
self.app.frigate_config.classification.custom.pop("delivery_service")
|
||||||
|
)
|
||||||
|
self.app.frigate_config.classification.custom[
|
||||||
|
"delivery.service"
|
||||||
|
].name = "delivery.service"
|
||||||
|
os.rename(self.model_dir, os.path.join(CLIPS_DIR, "delivery.service"))
|
||||||
|
self.model_dir = os.path.join(CLIPS_DIR, "delivery.service")
|
||||||
|
|
||||||
|
data = {"type": "object", "score": 0.9, "delivery.service": "DHL"}
|
||||||
|
Event.insert(
|
||||||
|
id="front",
|
||||||
|
label="car",
|
||||||
|
camera="front_door",
|
||||||
|
start_time=100,
|
||||||
|
end_time=200,
|
||||||
|
top_score=0.9,
|
||||||
|
score=0.9,
|
||||||
|
false_positive=False,
|
||||||
|
zones=[],
|
||||||
|
thumbnail="",
|
||||||
|
has_clip=True,
|
||||||
|
has_snapshot=True,
|
||||||
|
region=[],
|
||||||
|
box=[],
|
||||||
|
area=0,
|
||||||
|
retain_indefinitely=False,
|
||||||
|
ratio=1.0,
|
||||||
|
plus_id=None,
|
||||||
|
model_hash="",
|
||||||
|
detector_type="cpu",
|
||||||
|
model_type="ssd",
|
||||||
|
data=data,
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
assert self._get("limited_user").json() == ["DHL"]
|
||||||
|
|
||||||
|
def test_object_type_filters_out_unrelated_models(self):
|
||||||
|
self._insert_split_events()
|
||||||
|
assert self._get("limited_user", object_type="person").json() == []
|
||||||
|
assert self._get("limited_user", object_type="car").json() == ["DHL"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Tests for password change authorization."""
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from frigate.api.auth import get_current_user, hash_password, verify_password
|
||||||
|
from frigate.models import Event, Recordings, ReviewSegment, User
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
# Config carrying a custom role, which is the class of user the literal
|
||||||
|
# "viewer" check used to let through.
|
||||||
|
_CUSTOM_ROLE_CONFIG = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"auth": {"roles": {"neighbor": ["front_door"]}, "hash_iterations": 10},
|
||||||
|
"cameras": {
|
||||||
|
"front_door": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
|
||||||
|
},
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ADMIN_PASSWORD = "admin-real-password"
|
||||||
|
NEW_PASSWORD = "AttackerChosenPassword123!"
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdatePasswordAccess(BaseTestHttp):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp([Event, ReviewSegment, Recordings, User])
|
||||||
|
self.minimal_config = _CUSTOM_ROLE_CONFIG
|
||||||
|
self.app = super().create_app()
|
||||||
|
User.insert(
|
||||||
|
username="admin",
|
||||||
|
password_hash=hash_password(ADMIN_PASSWORD, iterations=10),
|
||||||
|
role="admin",
|
||||||
|
notification_tokens=[],
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
async def mock_get_current_user(request: Request):
|
||||||
|
return {
|
||||||
|
"username": request.headers.get("remote-user"),
|
||||||
|
"role": request.headers.get("remote-role"),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.app.dependency_overrides.clear()
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
|
def _change_password(self, actor: str, role: str, target: str, old_password: str):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
return client.put(
|
||||||
|
f"/users/{target}/password",
|
||||||
|
json={"password": NEW_PASSWORD, "old_password": old_password},
|
||||||
|
headers={"remote-user": actor, "remote-role": role},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _admin_password_unchanged(self) -> bool:
|
||||||
|
return verify_password(ADMIN_PASSWORD, User.get_by_id("admin").password_hash)
|
||||||
|
|
||||||
|
def test_custom_role_cannot_target_another_account(self):
|
||||||
|
resp = self._change_password("neighbor", "neighbor", "admin", "wrong-guess")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert self._admin_password_unchanged()
|
||||||
|
|
||||||
|
def test_custom_role_cannot_target_another_account_with_correct_password(self):
|
||||||
|
# The 403 must land before old_password is checked, so knowing the
|
||||||
|
# target's password is not a way through
|
||||||
|
resp = self._change_password("neighbor", "neighbor", "admin", ADMIN_PASSWORD)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert self._admin_password_unchanged()
|
||||||
|
|
||||||
|
def test_viewer_cannot_target_another_account(self):
|
||||||
|
resp = self._change_password("viewer_user", "viewer", "admin", ADMIN_PASSWORD)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert self._admin_password_unchanged()
|
||||||
|
|
||||||
|
def test_admin_can_target_another_account(self):
|
||||||
|
User.insert(
|
||||||
|
username="neighbor",
|
||||||
|
password_hash=hash_password("neighbor-password", iterations=10),
|
||||||
|
role="neighbor",
|
||||||
|
notification_tokens=[],
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
resp = self._change_password("admin", "admin", "neighbor", "")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_non_admin_can_change_own_password(self):
|
||||||
|
User.insert(
|
||||||
|
username="neighbor",
|
||||||
|
password_hash=hash_password("neighbor-password", iterations=10),
|
||||||
|
role="neighbor",
|
||||||
|
notification_tokens=[],
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
resp = self._change_password(
|
||||||
|
"neighbor", "neighbor", "neighbor", "neighbor-password"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_non_admin_own_password_still_requires_old_password(self):
|
||||||
|
User.insert(
|
||||||
|
username="neighbor",
|
||||||
|
password_hash=hash_password("neighbor-password", iterations=10),
|
||||||
|
role="neighbor",
|
||||||
|
notification_tokens=[],
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
resp = self._change_password("neighbor", "neighbor", "neighbor", "wrong-guess")
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Tests for audio label mapping."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from frigate.events.audio import AudioTfl
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioTfl(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.detector = AudioTfl.__new__(AudioTfl)
|
||||||
|
self.detector.stop_event = threading.Event()
|
||||||
|
self.detector._default_labels = {
|
||||||
|
69: "dog",
|
||||||
|
70: "bark",
|
||||||
|
75: "whimper_dog",
|
||||||
|
117: "dogs",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_update_labelmap_replaces_and_resets_overrides(self):
|
||||||
|
self.detector.update_labelmap({69: "dogs", 70: "dogs"})
|
||||||
|
assert self.detector.labels == {
|
||||||
|
69: "dogs",
|
||||||
|
70: "dogs",
|
||||||
|
75: "whimper_dog",
|
||||||
|
117: "dogs",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.detector.update_labelmap({75: "whimper"})
|
||||||
|
assert self.detector.labels == {
|
||||||
|
69: "dog",
|
||||||
|
70: "bark",
|
||||||
|
75: "whimper",
|
||||||
|
117: "dogs",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_detect_returns_highest_scoring_detection_for_grouped_label(self):
|
||||||
|
self.detector.update_labelmap({69: "dogs", 70: "dogs", 75: "dogs"})
|
||||||
|
self.detector._detect_raw = Mock(
|
||||||
|
return_value=np.array(
|
||||||
|
[
|
||||||
|
[117, 0.95, -1, -1, -1, -1],
|
||||||
|
[70, 0.9, -1, -1, -1, -1],
|
||||||
|
[69, 0.8, -1, -1, -1, -1],
|
||||||
|
[75, 0.7, -1, -1, -1, -1],
|
||||||
|
],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
detections = self.detector.detect(np.array([], dtype=np.float32))
|
||||||
|
|
||||||
|
assert len(detections) == 1
|
||||||
|
assert detections[0][0] == "dogs"
|
||||||
|
self.assertAlmostEqual(detections[0][1], 0.95)
|
||||||
|
|
||||||
|
def test_each_dog_audio_label_maps_to_grouped_label(self):
|
||||||
|
self.detector.update_labelmap({69: "dogs", 70: "dogs", 75: "dogs"})
|
||||||
|
|
||||||
|
for class_id in (69, 70, 75):
|
||||||
|
with self.subTest(class_id=class_id):
|
||||||
|
self.detector._detect_raw = Mock(
|
||||||
|
return_value=np.array(
|
||||||
|
[[class_id, 0.9, -1, -1, -1, -1]], dtype=np.float32
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
detections = self.detector.detect(np.array([], dtype=np.float32))
|
||||||
|
|
||||||
|
assert len(detections) == 1
|
||||||
|
assert detections[0][0] == "dogs"
|
||||||
|
self.assertAlmostEqual(detections[0][1], 0.9)
|
||||||
@@ -9,6 +9,32 @@ from frigate.config.camera.updater import (
|
|||||||
CameraConfigUpdateSubscriber,
|
CameraConfigUpdateSubscriber,
|
||||||
)
|
)
|
||||||
from frigate.const import SUB_CACHE_TAG
|
from frigate.const import SUB_CACHE_TAG
|
||||||
|
from frigate.detectors.detector_config import SceneEnum
|
||||||
|
|
||||||
|
|
||||||
|
def _build_scene_frigate_config(scene: str | None) -> FrigateConfig:
|
||||||
|
detect = {"height": 1080, "width": 1920, "fps": 5}
|
||||||
|
if scene is not None:
|
||||||
|
detect["scene"] = scene
|
||||||
|
return FrigateConfig(
|
||||||
|
**{
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"models": [
|
||||||
|
{"devices": ["cpu"]},
|
||||||
|
{"scene": "outdoor", "devices": ["openvino:CPU"]},
|
||||||
|
],
|
||||||
|
"cameras": {
|
||||||
|
"front_door": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": detect,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_camera_config(sub_enabled: bool) -> CameraConfig:
|
def _build_camera_config(sub_enabled: bool) -> CameraConfig:
|
||||||
@@ -86,6 +112,32 @@ class TestRecordUpdateRecreatesFfmpegCmds(unittest.TestCase):
|
|||||||
|
|
||||||
assert not _has_sub_output(camera_config)
|
assert not _has_sub_output(camera_config)
|
||||||
|
|
||||||
|
@patch("frigate.detectors.detector_config.load_labels")
|
||||||
|
def test_removed_camera_readded_without_scene_gets_fresh_model(self, mock_labels):
|
||||||
|
mock_labels.return_value = {}
|
||||||
|
config = _build_scene_frigate_config("outdoor")
|
||||||
|
subscriber = CameraConfigUpdateSubscriber(
|
||||||
|
config, {}, [CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove]
|
||||||
|
)
|
||||||
|
assert config.model_for_camera("front_door").scene == SceneEnum.outdoor
|
||||||
|
|
||||||
|
subscriber.subscriber.check_for_update.side_effect = [
|
||||||
|
("config/cameras/front_door/remove", config.cameras["front_door"]),
|
||||||
|
(None, None),
|
||||||
|
]
|
||||||
|
subscriber.check_for_updates()
|
||||||
|
|
||||||
|
# recreating the camera through the wizard leaves the scene unset,
|
||||||
|
# so the removed camera's cached model must not carry over
|
||||||
|
readded = _build_scene_frigate_config(None).cameras["front_door"]
|
||||||
|
subscriber.subscriber.check_for_update.side_effect = [
|
||||||
|
("config/cameras/front_door/add", readded),
|
||||||
|
(None, None),
|
||||||
|
]
|
||||||
|
subscriber.check_for_updates()
|
||||||
|
|
||||||
|
assert config.model_for_camera("front_door").scene == SceneEnum.all
|
||||||
|
|
||||||
def test_unchanged_record_update_keeps_existing_cmds(self):
|
def test_unchanged_record_update_keeps_existing_cmds(self):
|
||||||
camera_config = _build_camera_config(sub_enabled=False)
|
camera_config = _build_camera_config(sub_enabled=False)
|
||||||
subscriber = CameraConfigUpdateSubscriber(
|
subscriber = CameraConfigUpdateSubscriber(
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Tests for per stream recording health tracking in the camera watchdog."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
|
||||||
|
from frigate.video.ffmpeg import CameraWatchdog
|
||||||
|
|
||||||
|
|
||||||
|
class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
||||||
|
def _build_watchdog(
|
||||||
|
self, sub_enabled: bool = True, output_args: dict | None = None
|
||||||
|
) -> CameraWatchdog:
|
||||||
|
config = FrigateConfig(
|
||||||
|
**{
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"cameras": {
|
||||||
|
"front_door": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"output_args": output_args or {},
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
|
"roles": ["record"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video2",
|
||||||
|
"roles": ["detect", "record_sub"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"enabled": True,
|
||||||
|
"sub": {"enabled": sub_enabled},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
camera_config = config.cameras["front_door"]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("frigate.video.ffmpeg.LogPipe"),
|
||||||
|
patch("frigate.video.ffmpeg.InterProcessRequestor"),
|
||||||
|
patch("frigate.video.ffmpeg.RecordingsDataSubscriber"),
|
||||||
|
patch("frigate.video.ffmpeg.CameraConfigUpdateSubscriber"),
|
||||||
|
):
|
||||||
|
watchdog = CameraWatchdog(
|
||||||
|
camera_config,
|
||||||
|
1,
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
watchdog.requestor = MagicMock()
|
||||||
|
return watchdog
|
||||||
|
|
||||||
|
def test_stale_sub_does_not_mark_main_stale(self):
|
||||||
|
watchdog = self._build_watchdog()
|
||||||
|
now = datetime.now().astimezone(UTC)
|
||||||
|
stale = (now - timedelta(hours=1)).timestamp()
|
||||||
|
|
||||||
|
watchdog.latest_cache_segment_time[STREAM_TYPE_MAIN] = now.timestamp()
|
||||||
|
watchdog.latest_valid_segment_time[STREAM_TYPE_MAIN] = now.timestamp()
|
||||||
|
watchdog.latest_cache_segment_time[STREAM_TYPE_SUB] = stale
|
||||||
|
watchdog.latest_valid_segment_time[STREAM_TYPE_SUB] = stale
|
||||||
|
|
||||||
|
assert watchdog._stream_staleness(STREAM_TYPE_MAIN, now) is None
|
||||||
|
assert watchdog._stream_staleness(STREAM_TYPE_SUB, now) is not None
|
||||||
|
|
||||||
|
def test_stale_main_does_not_mark_sub_stale(self):
|
||||||
|
watchdog = self._build_watchdog()
|
||||||
|
now = datetime.now().astimezone(UTC)
|
||||||
|
stale = (now - timedelta(hours=1)).timestamp()
|
||||||
|
|
||||||
|
watchdog.latest_cache_segment_time[STREAM_TYPE_MAIN] = stale
|
||||||
|
watchdog.latest_valid_segment_time[STREAM_TYPE_MAIN] = stale
|
||||||
|
watchdog.latest_cache_segment_time[STREAM_TYPE_SUB] = now.timestamp()
|
||||||
|
watchdog.latest_valid_segment_time[STREAM_TYPE_SUB] = now.timestamp()
|
||||||
|
|
||||||
|
assert watchdog._stream_staleness(STREAM_TYPE_MAIN, now) is not None
|
||||||
|
assert watchdog._stream_staleness(STREAM_TYPE_SUB, now) is None
|
||||||
|
|
||||||
|
def test_grace_period_suppresses_staleness(self):
|
||||||
|
watchdog = self._build_watchdog()
|
||||||
|
now = datetime.now().astimezone(UTC)
|
||||||
|
watchdog.record_enable_time = now - timedelta(seconds=10)
|
||||||
|
watchdog.latest_cache_segment_time[STREAM_TYPE_SUB] = (
|
||||||
|
now - timedelta(hours=1)
|
||||||
|
).timestamp()
|
||||||
|
|
||||||
|
assert watchdog._stream_staleness(STREAM_TYPE_SUB, now) is None
|
||||||
|
|
||||||
|
def test_status_goes_to_the_matching_role_topic(self):
|
||||||
|
watchdog = self._build_watchdog()
|
||||||
|
|
||||||
|
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||||
|
watchdog._send_record_status(STREAM_TYPE_SUB, "offline", 100.0)
|
||||||
|
|
||||||
|
watchdog.requestor.send_data.assert_any_call(
|
||||||
|
"front_door/status/record", "online"
|
||||||
|
)
|
||||||
|
watchdog.requestor.send_data.assert_any_call(
|
||||||
|
"front_door/status/record_sub", "offline"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_is_cached_per_stream(self):
|
||||||
|
watchdog = self._build_watchdog()
|
||||||
|
|
||||||
|
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||||
|
watchdog._send_record_status(STREAM_TYPE_SUB, "online", 100.0)
|
||||||
|
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
|
||||||
|
|
||||||
|
assert watchdog.requestor.send_data.call_count == 2
|
||||||
|
|
||||||
|
def test_recorded_streams_follows_config(self):
|
||||||
|
watchdog = self._build_watchdog()
|
||||||
|
assert watchdog._recorded_streams(["record"]) == [STREAM_TYPE_MAIN]
|
||||||
|
assert watchdog._recorded_streams(["detect", "record_sub"]) == [STREAM_TYPE_SUB]
|
||||||
|
assert watchdog._recorded_streams(["detect"]) == []
|
||||||
|
|
||||||
|
disabled = self._build_watchdog(sub_enabled=False)
|
||||||
|
assert disabled._recorded_streams(["detect", "record_sub"]) == []
|
||||||
|
|
||||||
|
def test_restart_grace_suppresses_repeat_staleness(self):
|
||||||
|
watchdog = self._build_watchdog()
|
||||||
|
now = datetime.now().astimezone(UTC)
|
||||||
|
stale = (now - timedelta(hours=1)).timestamp()
|
||||||
|
|
||||||
|
watchdog.latest_cache_segment_time[STREAM_TYPE_MAIN] = stale
|
||||||
|
watchdog.latest_valid_segment_time[STREAM_TYPE_MAIN] = stale
|
||||||
|
assert watchdog._stream_staleness(STREAM_TYPE_MAIN, now) is not None
|
||||||
|
|
||||||
|
watchdog._grant_restart_grace([STREAM_TYPE_MAIN], now)
|
||||||
|
|
||||||
|
assert watchdog._stream_staleness(STREAM_TYPE_MAIN, now) is None
|
||||||
|
assert (
|
||||||
|
watchdog._stream_staleness(STREAM_TYPE_MAIN, now + timedelta(seconds=89))
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
watchdog._stream_staleness(STREAM_TYPE_MAIN, now + timedelta(seconds=91))
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_restart_grace_is_per_stream(self):
|
||||||
|
watchdog = self._build_watchdog()
|
||||||
|
now = datetime.now().astimezone(UTC)
|
||||||
|
stale = (now - timedelta(hours=1)).timestamp()
|
||||||
|
|
||||||
|
for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB):
|
||||||
|
watchdog.latest_cache_segment_time[stream_type] = stale
|
||||||
|
watchdog.latest_valid_segment_time[stream_type] = stale
|
||||||
|
|
||||||
|
watchdog._grant_restart_grace([STREAM_TYPE_MAIN], now)
|
||||||
|
|
||||||
|
assert watchdog._stream_staleness(STREAM_TYPE_MAIN, now) is None
|
||||||
|
assert watchdog._stream_staleness(STREAM_TYPE_SUB, now) is not None
|
||||||
|
|
||||||
|
def test_detect_reset_grants_the_shared_sub_stream_grace(self):
|
||||||
|
watchdog = self._build_watchdog()
|
||||||
|
watchdog.detect_process_records_sub = True
|
||||||
|
watchdog.ffmpeg_detect_process = MagicMock()
|
||||||
|
watchdog.capture_thread = MagicMock()
|
||||||
|
watchdog.capture_thread.is_alive.return_value = False
|
||||||
|
watchdog.start_ffmpeg_detect = MagicMock()
|
||||||
|
|
||||||
|
now = datetime.now().astimezone(UTC)
|
||||||
|
stale = (now - timedelta(hours=1)).timestamp()
|
||||||
|
watchdog.latest_cache_segment_time[STREAM_TYPE_SUB] = stale
|
||||||
|
watchdog.latest_valid_segment_time[STREAM_TYPE_SUB] = stale
|
||||||
|
assert watchdog._stream_staleness(STREAM_TYPE_SUB, now) is not None
|
||||||
|
|
||||||
|
watchdog.reset_capture_thread(terminate=False)
|
||||||
|
|
||||||
|
# the sub check runs later in the same tick against a stale can_restart,
|
||||||
|
# so without this grace it would kill the just-restarted process again
|
||||||
|
assert (
|
||||||
|
watchdog._stream_staleness(STREAM_TYPE_SUB, datetime.now().astimezone(UTC))
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_detect_reset_leaves_sub_alone_when_not_shared(self):
|
||||||
|
watchdog = self._build_watchdog()
|
||||||
|
watchdog.detect_process_records_sub = False
|
||||||
|
watchdog.ffmpeg_detect_process = MagicMock()
|
||||||
|
watchdog.capture_thread = MagicMock()
|
||||||
|
watchdog.capture_thread.is_alive.return_value = False
|
||||||
|
watchdog.start_ffmpeg_detect = MagicMock()
|
||||||
|
|
||||||
|
now = datetime.now().astimezone(UTC)
|
||||||
|
stale = (now - timedelta(hours=1)).timestamp()
|
||||||
|
watchdog.latest_cache_segment_time[STREAM_TYPE_SUB] = stale
|
||||||
|
watchdog.latest_valid_segment_time[STREAM_TYPE_SUB] = stale
|
||||||
|
|
||||||
|
watchdog.reset_capture_thread(terminate=False)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
watchdog._stream_staleness(STREAM_TYPE_SUB, datetime.now().astimezone(UTC))
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stale_threshold_follows_each_stream_segment_time(self):
|
||||||
|
watchdog = self._build_watchdog(
|
||||||
|
output_args={
|
||||||
|
"record": "-f segment -segment_time 10 -c copy",
|
||||||
|
"record_sub": "-f segment -segment_time 60 -c copy",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert watchdog.record_stale_threshold[STREAM_TYPE_MAIN] == 120
|
||||||
|
assert watchdog.record_stale_threshold[STREAM_TYPE_SUB] == 150
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Tests for the recording clip download stream."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess as sp
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from frigate.api.media import _run_clip_download
|
||||||
|
|
||||||
|
# more than the 64 KB a pipe holds, so an undrained stderr blocks ffmpeg
|
||||||
|
STDERR_FLOOD_BYTES = 256 * 1024
|
||||||
|
PAYLOAD = b"0123456789" * 512
|
||||||
|
|
||||||
|
|
||||||
|
def fake_ffmpeg(*statements: str) -> list[str]:
|
||||||
|
"""Build an argv that stands in for ffmpeg, running the given statements."""
|
||||||
|
return [sys.executable, "-c", "\n".join(("import sys, time", *statements))]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunClipDownload(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
handle, self.playlist_path = tempfile.mkstemp(suffix=".txt")
|
||||||
|
os.close(handle)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if os.path.exists(self.playlist_path):
|
||||||
|
os.unlink(self.playlist_path)
|
||||||
|
|
||||||
|
def collect(self, ffmpeg_cmd: list[str], timeout: float = 30.0) -> bytes:
|
||||||
|
"""Drain the generator on a worker thread so a deadlock fails the test."""
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
errors: list[BaseException] = []
|
||||||
|
|
||||||
|
def drain() -> None:
|
||||||
|
try:
|
||||||
|
chunks.extend(_run_clip_download(ffmpeg_cmd, self.playlist_path))
|
||||||
|
except BaseException as err:
|
||||||
|
errors.append(err)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=drain, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
thread.join(timeout)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
thread.is_alive(), "clip download did not finish, ffmpeg is deadlocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise errors[0]
|
||||||
|
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
def test_streams_full_clip_when_ffmpeg_floods_stderr(self):
|
||||||
|
"""A warning flood past the pipe buffer must not stall the download."""
|
||||||
|
data = self.collect(
|
||||||
|
fake_ffmpeg(
|
||||||
|
f"sys.stderr.write('w' * {STDERR_FLOOD_BYTES})",
|
||||||
|
"sys.stderr.flush()",
|
||||||
|
f"sys.stdout.buffer.write({PAYLOAD!r})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(data, PAYLOAD)
|
||||||
|
self.assertFalse(os.path.exists(self.playlist_path))
|
||||||
|
|
||||||
|
def test_streams_clip_written_before_stderr_flood(self):
|
||||||
|
data = self.collect(
|
||||||
|
fake_ffmpeg(
|
||||||
|
f"sys.stdout.buffer.write({PAYLOAD!r})",
|
||||||
|
"sys.stdout.flush()",
|
||||||
|
f"sys.stderr.write('w' * {STDERR_FLOOD_BYTES})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(data, PAYLOAD)
|
||||||
|
|
||||||
|
def test_logs_ffmpeg_output_and_removes_playlist_on_failure(self):
|
||||||
|
with patch("frigate.api.media.logger") as logger:
|
||||||
|
data = self.collect(
|
||||||
|
fake_ffmpeg(
|
||||||
|
"sys.stderr.write('something went wrong')",
|
||||||
|
"sys.exit(1)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(data, b"")
|
||||||
|
logger.error.assert_called_once()
|
||||||
|
self.assertIn("something went wrong", logger.error.call_args.args[1])
|
||||||
|
self.assertFalse(os.path.exists(self.playlist_path))
|
||||||
|
|
||||||
|
def test_logs_only_the_tail_of_a_flooded_stderr(self):
|
||||||
|
with patch("frigate.api.media.logger") as logger:
|
||||||
|
self.collect(
|
||||||
|
fake_ffmpeg(
|
||||||
|
f"sys.stderr.write('w' * {STDERR_FLOOD_BYTES})",
|
||||||
|
"sys.exit(1)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logged = logger.error.call_args.args[1]
|
||||||
|
self.assertLess(len(logged), STDERR_FLOOD_BYTES)
|
||||||
|
|
||||||
|
def test_does_not_log_a_successful_download(self):
|
||||||
|
with patch("frigate.api.media.logger") as logger:
|
||||||
|
self.collect(fake_ffmpeg(f"sys.stdout.buffer.write({PAYLOAD!r})"))
|
||||||
|
|
||||||
|
logger.error.assert_not_called()
|
||||||
|
|
||||||
|
def test_removes_playlist_when_ffmpeg_cannot_start(self):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
self.collect(["/nonexistent-ffmpeg-binary"])
|
||||||
|
|
||||||
|
self.assertFalse(os.path.exists(self.playlist_path))
|
||||||
|
|
||||||
|
def test_closes_the_stdout_pipe_after_a_successful_download(self):
|
||||||
|
processes: list[sp.Popen] = []
|
||||||
|
real_popen = sp.Popen
|
||||||
|
|
||||||
|
def spy(*args, **kwargs):
|
||||||
|
process = real_popen(*args, **kwargs)
|
||||||
|
processes.append(process)
|
||||||
|
return process
|
||||||
|
|
||||||
|
with patch("subprocess.Popen", spy):
|
||||||
|
self.collect(fake_ffmpeg(f"sys.stdout.buffer.write({PAYLOAD!r})"))
|
||||||
|
|
||||||
|
self.assertTrue(processes[0].stdout.closed)
|
||||||
|
|
||||||
|
def test_terminating_a_lingering_ffmpeg_is_not_logged_as_a_failure(self):
|
||||||
|
"""A complete download whose ffmpeg overstays is a success, not an error."""
|
||||||
|
lingering = fake_ffmpeg(
|
||||||
|
"import os",
|
||||||
|
f"os.write(1, {PAYLOAD!r})",
|
||||||
|
"os.close(1)",
|
||||||
|
"time.sleep(30)",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("frigate.api.media.CLIP_FFMPEG_EXIT_TIMEOUT", 0.5):
|
||||||
|
with patch("frigate.api.media.logger") as logger:
|
||||||
|
data = self.collect(lingering)
|
||||||
|
|
||||||
|
self.assertEqual(data, PAYLOAD)
|
||||||
|
logger.error.assert_not_called()
|
||||||
|
|
||||||
|
def test_client_disconnect_kills_ffmpeg_and_removes_playlist(self):
|
||||||
|
processes: list[sp.Popen] = []
|
||||||
|
real_popen = sp.Popen
|
||||||
|
|
||||||
|
def spy(*args, **kwargs):
|
||||||
|
process = real_popen(*args, **kwargs)
|
||||||
|
processes.append(process)
|
||||||
|
return process
|
||||||
|
|
||||||
|
forever = fake_ffmpeg(
|
||||||
|
"while True:",
|
||||||
|
" sys.stdout.buffer.write(b'x' * 4096)",
|
||||||
|
" sys.stdout.flush()",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("subprocess.Popen", spy):
|
||||||
|
stream = _run_clip_download(forever, self.playlist_path)
|
||||||
|
self.assertTrue(next(stream))
|
||||||
|
# Starlette never closes the generator itself, so a real disconnect
|
||||||
|
# reaches this path only once the frame is finalized
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
self.assertIsNotNone(processes[0].poll(), "ffmpeg outlived the request")
|
||||||
|
self.assertFalse(os.path.exists(self.playlist_path))
|
||||||
+118
-1
@@ -159,7 +159,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
FrigateConfig(**(deep_merge(config, self.minimal)))
|
FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||||
|
|
||||||
@patch("frigate.detectors.detector_config.load_labels")
|
@patch("frigate.detectors.detector_config.load_labels")
|
||||||
def test_camera_scene_must_match_a_model(self, mock_labels):
|
def test_camera_scene_without_a_model_falls_back_to_all(self, mock_labels):
|
||||||
mock_labels.return_value = {}
|
mock_labels.return_value = {}
|
||||||
config = {
|
config = {
|
||||||
"models": [{"devices": ["cpu"]}],
|
"models": [{"devices": ["cpu"]}],
|
||||||
@@ -175,6 +175,71 @@ class TestConfig(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||||
|
|
||||||
|
assert frigate_config.model_for_camera("back").scene == SceneEnum.all
|
||||||
|
|
||||||
|
@patch("frigate.detectors.detector_config.load_labels")
|
||||||
|
def test_model_for_camera_resolves_camera_added_after_parse(self, mock_labels):
|
||||||
|
mock_labels.return_value = {}
|
||||||
|
config = {
|
||||||
|
"models": [
|
||||||
|
{"devices": ["cpu"], "width": 320},
|
||||||
|
{"scene": "outdoor", "devices": ["openvino:CPU"], "width": 416},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
frigate_config = FrigateConfig(**(deep_merge(deepcopy(config), self.minimal)))
|
||||||
|
|
||||||
|
# runtime camera adds (wizard, clone, debug replay) insert an already
|
||||||
|
# resolved camera into the shared config without re-running parse
|
||||||
|
added = deepcopy(self.minimal)
|
||||||
|
added["cameras"]["new_cam"] = {
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5, "scene": "outdoor"},
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
new_config = FrigateConfig(**(deep_merge(deepcopy(config), added)))
|
||||||
|
frigate_config.cameras["new_cam"] = new_config.cameras["new_cam"]
|
||||||
|
|
||||||
|
assert frigate_config.model_for_camera("new_cam").scene == SceneEnum.outdoor
|
||||||
|
assert frigate_config.model_for_camera("new_cam").width == 416
|
||||||
|
|
||||||
|
@patch("frigate.detectors.detector_config.load_labels")
|
||||||
|
def test_model_for_camera_unknown_camera_uses_default_model(self, mock_labels):
|
||||||
|
mock_labels.return_value = {}
|
||||||
|
config = {
|
||||||
|
"models": [
|
||||||
|
{"devices": ["cpu"], "width": 320},
|
||||||
|
{"scene": "outdoor", "devices": ["openvino:CPU"], "width": 416},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
frigate_config = FrigateConfig(**(deep_merge(deepcopy(config), self.minimal)))
|
||||||
|
|
||||||
|
# a caller racing a runtime remove may still name the popped camera
|
||||||
|
assert frigate_config.model_for_camera("removed").scene == SceneEnum.all
|
||||||
|
|
||||||
|
@patch("frigate.detectors.detector_config.load_labels")
|
||||||
|
def test_camera_scene_without_a_model_or_a_default(self, mock_labels):
|
||||||
|
mock_labels.return_value = {}
|
||||||
|
config = {
|
||||||
|
"models": [{"scene": "indoor", "devices": ["cpu"]}],
|
||||||
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"detect": {"scene": "outdoor"},
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
FrigateConfig(**(deep_merge(config, self.minimal)))
|
FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||||
|
|
||||||
@@ -1164,6 +1229,36 @@ class TestConfig(unittest.TestCase):
|
|||||||
lambda: FrigateConfig(**config).cameras,
|
lambda: FrigateConfig(**config).cameras,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_fails_on_record_and_record_sub_on_same_input(self):
|
||||||
|
config = self._sub_record_config()
|
||||||
|
config["cameras"]["back"]["ffmpeg"]["inputs"] = [
|
||||||
|
{
|
||||||
|
"path": "rtsp://10.0.0.1:554/video",
|
||||||
|
"roles": ["detect", "record", "record_sub"],
|
||||||
|
},
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video2", "roles": ["audio"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertRaisesRegex(
|
||||||
|
ValueError,
|
||||||
|
"record and record_sub assigned to the same input",
|
||||||
|
lambda: FrigateConfig(**config).cameras,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fails_on_record_sub_with_a_single_input(self):
|
||||||
|
# the single input case has record forced onto it, so record_sub can
|
||||||
|
# only ever duplicate that same stream
|
||||||
|
config = self._sub_record_config()
|
||||||
|
config["cameras"]["back"]["ffmpeg"]["inputs"] = [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect", "record_sub"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertRaisesRegex(
|
||||||
|
ValueError,
|
||||||
|
"record and record_sub assigned to the same input",
|
||||||
|
lambda: FrigateConfig(**config).cameras,
|
||||||
|
)
|
||||||
|
|
||||||
def test_record_sub_segment_time_not_checked_when_disabled(self):
|
def test_record_sub_segment_time_not_checked_when_disabled(self):
|
||||||
config = self._sub_record_config(
|
config = self._sub_record_config(
|
||||||
{
|
{
|
||||||
@@ -1278,6 +1373,28 @@ class TestConfig(unittest.TestCase):
|
|||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert frigate_config.primary_model.merged_labelmap[7] == "truck"
|
assert frigate_config.primary_model.merged_labelmap[7] == "truck"
|
||||||
|
|
||||||
|
def test_audio_labelmap_inheritance_is_separate_from_model_labelmap(self):
|
||||||
|
config = deep_merge(
|
||||||
|
{
|
||||||
|
"audio": {"labelmap": {69: "dogs", 70: "dogs"}},
|
||||||
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"audio": {"labelmap": {75: "dogs"}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
self.minimal,
|
||||||
|
)
|
||||||
|
|
||||||
|
frigate_config = FrigateConfig(**config)
|
||||||
|
|
||||||
|
assert frigate_config.cameras["back"].audio.labelmap == {
|
||||||
|
69: "dogs",
|
||||||
|
70: "dogs",
|
||||||
|
75: "dogs",
|
||||||
|
}
|
||||||
|
assert frigate_config.primary_model.merged_labelmap[69] != "dogs"
|
||||||
|
|
||||||
def test_default_labelmap_empty(self):
|
def test_default_labelmap_empty(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
from frigate.debug_replay import DebugReplayManager
|
from frigate.debug_replay import DebugReplayManager
|
||||||
from frigate.jobs.debug_replay import (
|
from frigate.jobs.debug_replay import (
|
||||||
DebugReplayJob,
|
DebugReplayJob,
|
||||||
|
NoRecordingsError,
|
||||||
RecordingDebugReplaySource,
|
RecordingDebugReplaySource,
|
||||||
cancel_debug_replay_job,
|
cancel_debug_replay_job,
|
||||||
get_active_runner,
|
get_active_runner,
|
||||||
@@ -129,7 +130,7 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
|||||||
empty_qs = MagicMock()
|
empty_qs = MagicMock()
|
||||||
empty_qs.count.return_value = 0
|
empty_qs.count.return_value = 0
|
||||||
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
|
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(NoRecordingsError):
|
||||||
start_debug_replay_job(
|
start_debug_replay_job(
|
||||||
source=RecordingDebugReplaySource(
|
source=RecordingDebugReplaySource(
|
||||||
source_camera="front",
|
source_camera="front",
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Tests for detection hardware discovery."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from frigate.detectors import hardware
|
||||||
|
from frigate.detectors.detector_types import config_types
|
||||||
|
from frigate.detectors.hardware import HardwareProber
|
||||||
|
|
||||||
|
|
||||||
|
def write(path: str, content: str = "") -> None:
|
||||||
|
"""Create a file and any parent directories."""
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareProbeTestCase(unittest.TestCase):
|
||||||
|
"""Points every probe at an empty fixture tree, so nothing is found by default."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.root = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.root.cleanup)
|
||||||
|
|
||||||
|
for name in ("SYS_ROOT", "DEV_ROOT", "PROC_ROOT", "ETC_ROOT"):
|
||||||
|
sub = os.path.join(self.root.name, name.split("_")[0].lower())
|
||||||
|
os.makedirs(sub, exist_ok=True)
|
||||||
|
patcher = patch.object(hardware, name, sub)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
setattr(self, name.lower(), sub)
|
||||||
|
|
||||||
|
drm = patch.object(hardware, "enumerate_drm_devices", return_value={})
|
||||||
|
self.drm = drm.start()
|
||||||
|
self.addCleanup(drm.stop)
|
||||||
|
|
||||||
|
def probe(self) -> dict[str, hardware.DetectionHardware]:
|
||||||
|
return {found.key: found for found in HardwareProber().probe()}
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoHardware(HardwareProbeTestCase):
|
||||||
|
def test_only_the_cpu_is_reported(self):
|
||||||
|
self.assertEqual(list(self.probe()), ["cpu"])
|
||||||
|
|
||||||
|
def test_the_cpu_is_unlimited(self):
|
||||||
|
self.assertTrue(self.probe()["cpu"].unlimited)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCoral(HardwareProbeTestCase):
|
||||||
|
def test_each_apex_device_is_a_unit(self):
|
||||||
|
for name in ("apex_0", "apex_1"):
|
||||||
|
os.makedirs(os.path.join(self.sys_root, "class", "apex", name))
|
||||||
|
|
||||||
|
coral = self.probe()["edgetpu:pci"]
|
||||||
|
|
||||||
|
self.assertEqual(coral.count, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
[unit.device for unit in coral.units],
|
||||||
|
["edgetpu:pci:0", "edgetpu:pci:1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_coral_is_not_unlimited(self):
|
||||||
|
os.makedirs(os.path.join(self.sys_root, "class", "apex", "apex_0"))
|
||||||
|
|
||||||
|
self.assertFalse(self.probe()["edgetpu:pci"].unlimited)
|
||||||
|
|
||||||
|
def test_usb_corals_are_found_by_their_usb_ids(self):
|
||||||
|
usb = os.path.join(self.sys_root, "bus", "usb", "devices")
|
||||||
|
# a Coral reports as Global Unichip before its firmware loads
|
||||||
|
write(os.path.join(usb, "1-1", "idVendor"), "1a6e")
|
||||||
|
write(os.path.join(usb, "1-1", "idProduct"), "089a")
|
||||||
|
# and as Google afterwards
|
||||||
|
write(os.path.join(usb, "1-2", "idVendor"), "18d1")
|
||||||
|
write(os.path.join(usb, "1-2", "idProduct"), "9302")
|
||||||
|
|
||||||
|
coral = self.probe()["edgetpu:usb"]
|
||||||
|
|
||||||
|
self.assertEqual(coral.count, 2)
|
||||||
|
self.assertEqual(coral.units[0].device, "edgetpu:usb:0")
|
||||||
|
|
||||||
|
def test_other_usb_devices_are_ignored(self):
|
||||||
|
usb = os.path.join(self.sys_root, "bus", "usb", "devices")
|
||||||
|
write(os.path.join(usb, "1-1", "idVendor"), "046d")
|
||||||
|
write(os.path.join(usb, "1-1", "idProduct"), "0825")
|
||||||
|
|
||||||
|
self.assertNotIn("edgetpu:usb", self.probe())
|
||||||
|
|
||||||
|
|
||||||
|
class TestGpus(HardwareProbeTestCase):
|
||||||
|
def test_a_single_intel_gpu_is_the_unnumbered_device(self):
|
||||||
|
self.drm.return_value = {"0000:00:02.0": "i915"}
|
||||||
|
|
||||||
|
gpu = self.probe()["openvino:GPU"]
|
||||||
|
|
||||||
|
self.assertEqual([unit.device for unit in gpu.units], ["openvino:GPU"])
|
||||||
|
self.assertTrue(gpu.unlimited)
|
||||||
|
|
||||||
|
def test_multiple_intel_gpus_are_numbered(self):
|
||||||
|
self.drm.return_value = {"0000:00:02.0": "i915", "0000:03:00.0": "xe"}
|
||||||
|
|
||||||
|
gpu = self.probe()["openvino:GPU"]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[unit.device for unit in gpu.units],
|
||||||
|
["openvino:GPU.0", "openvino:GPU.1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_gpu_drm_devices_are_ignored(self):
|
||||||
|
self.drm.return_value = {"0000:00:02.0": "virtio-mmio"}
|
||||||
|
|
||||||
|
self.assertNotIn("openvino:GPU", self.probe())
|
||||||
|
|
||||||
|
def test_amd_gpus_run_through_onnx(self):
|
||||||
|
self.drm.return_value = {"0000:03:00.0": "amdgpu"}
|
||||||
|
|
||||||
|
self.assertEqual(self.probe()["onnx:amd"].units[0].device, "onnx")
|
||||||
|
|
||||||
|
def test_an_intel_npu_is_found_by_its_driver(self):
|
||||||
|
accel = os.path.join(self.sys_root, "class", "accel", "accel0", "device")
|
||||||
|
os.makedirs(accel)
|
||||||
|
os.symlink("/drivers/intel_vpu", os.path.join(accel, "driver"))
|
||||||
|
|
||||||
|
self.assertEqual(self.probe()["openvino:NPU"].units[0].device, "openvino:NPU")
|
||||||
|
|
||||||
|
def test_other_accel_devices_are_ignored(self):
|
||||||
|
accel = os.path.join(self.sys_root, "class", "accel", "accel0", "device")
|
||||||
|
os.makedirs(accel)
|
||||||
|
os.symlink("/drivers/something_else", os.path.join(accel, "driver"))
|
||||||
|
|
||||||
|
self.assertNotIn("openvino:NPU", self.probe())
|
||||||
|
|
||||||
|
|
||||||
|
class TestNvidia(HardwareProbeTestCase):
|
||||||
|
def _add_gpu(self, address: str, model: str) -> None:
|
||||||
|
write(
|
||||||
|
os.path.join(
|
||||||
|
self.proc_root, "driver", "nvidia", "gpus", address, "information"
|
||||||
|
),
|
||||||
|
f"Model: \t {model}\nIRQ: \t 62\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_model_name_is_read_from_proc(self):
|
||||||
|
self._add_gpu("0000:01:00.0", "NVIDIA GeForce RTX 3060")
|
||||||
|
|
||||||
|
gpu = self.probe()["onnx:nvidia"]
|
||||||
|
|
||||||
|
self.assertEqual(gpu.name, "NVIDIA GeForce RTX 3060")
|
||||||
|
self.assertEqual(gpu.units[0].device, "onnx:0")
|
||||||
|
|
||||||
|
def test_multiple_gpus_are_indexed(self):
|
||||||
|
self._add_gpu("0000:01:00.0", "NVIDIA GeForce RTX 3060")
|
||||||
|
self._add_gpu("0000:02:00.0", "NVIDIA GeForce RTX 4090")
|
||||||
|
|
||||||
|
gpu = self.probe()["onnx:nvidia"]
|
||||||
|
|
||||||
|
self.assertEqual(gpu.name, "NVIDIA GPU")
|
||||||
|
self.assertEqual([unit.device for unit in gpu.units], ["onnx:0", "onnx:1"])
|
||||||
|
self.assertEqual(gpu.units[1].label, "NVIDIA GeForce RTX 4090")
|
||||||
|
|
||||||
|
def test_a_jetson_runs_through_tensorrt(self):
|
||||||
|
write(os.path.join(self.etc_root, "nv_tegra_release"), "# R36 (release)")
|
||||||
|
|
||||||
|
self.assertEqual(self.probe()["tensorrt"].units[0].device, "tensorrt:0")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccelerators(HardwareProbeTestCase):
|
||||||
|
def test_hailo_is_found_by_its_device_node(self):
|
||||||
|
write(os.path.join(self.dev_root, "hailo0"))
|
||||||
|
|
||||||
|
self.assertEqual(self.probe()["hailo8l"].units[0].device, "hailo8l:PCIe")
|
||||||
|
|
||||||
|
def test_each_memryx_node_is_a_unit(self):
|
||||||
|
write(os.path.join(self.dev_root, "memx0"))
|
||||||
|
write(os.path.join(self.dev_root, "memx1"))
|
||||||
|
|
||||||
|
memryx = self.probe()["memryx"]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[unit.device for unit in memryx.units],
|
||||||
|
["memryx:PCIe:0", "memryx:PCIe:1"],
|
||||||
|
)
|
||||||
|
self.assertFalse(memryx.unlimited)
|
||||||
|
|
||||||
|
def test_a_supported_rockchip_soc_is_reported(self):
|
||||||
|
write(
|
||||||
|
os.path.join(self.proc_root, "device-tree", "compatible"),
|
||||||
|
"rockchip,rk3588\x00",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(self.probe()["rknn"].units[0].device, "rknn")
|
||||||
|
|
||||||
|
def test_an_unsupported_soc_is_ignored(self):
|
||||||
|
write(
|
||||||
|
os.path.join(self.proc_root, "device-tree", "compatible"),
|
||||||
|
"nvidia,tegra\x00",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn("rknn", self.probe())
|
||||||
|
|
||||||
|
def test_axengine_is_found_by_its_control_node(self):
|
||||||
|
write(os.path.join(self.dev_root, "axcl_host"))
|
||||||
|
|
||||||
|
self.assertEqual(self.probe()["axengine"].units[0].device, "axengine")
|
||||||
|
|
||||||
|
def test_synaptics_is_found_by_its_device_node(self):
|
||||||
|
write(os.path.join(self.dev_root, "synap"))
|
||||||
|
|
||||||
|
self.assertEqual(self.probe()["synaptics"].units[0].device, "synaptics")
|
||||||
|
|
||||||
|
|
||||||
|
class TestProber(HardwareProbeTestCase):
|
||||||
|
def test_the_result_is_cached_until_refreshed(self):
|
||||||
|
prober = HardwareProber()
|
||||||
|
self.assertNotIn("edgetpu:pci", {found.key for found in prober.probe()})
|
||||||
|
|
||||||
|
os.makedirs(os.path.join(self.sys_root, "class", "apex", "apex_0"))
|
||||||
|
|
||||||
|
self.assertNotIn("edgetpu:pci", {found.key for found in prober.probe()})
|
||||||
|
self.assertIn(
|
||||||
|
"edgetpu:pci", {found.key for found in prober.probe(refresh=True)}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_failing_probe_does_not_break_the_rest(self):
|
||||||
|
with patch.object(hardware, "detect_hailo", side_effect=OSError("boom")):
|
||||||
|
self.assertIn("cpu", self.probe())
|
||||||
|
|
||||||
|
def test_unlimited_tracks_the_detector_shareable_flag(self):
|
||||||
|
for name in ("apex_0",):
|
||||||
|
os.makedirs(os.path.join(self.sys_root, "class", "apex", name))
|
||||||
|
write(os.path.join(self.dev_root, "memx0"))
|
||||||
|
self.drm.return_value = {"0000:00:02.0": "i915"}
|
||||||
|
|
||||||
|
for found in self.probe().values():
|
||||||
|
config_class = config_types.get(found.detector)
|
||||||
|
|
||||||
|
if config_class is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
with self.subTest(hardware=found.key):
|
||||||
|
self.assertEqual(found.unlimited, config_class.shareable)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user