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
|
||||||
|
|
||||||
exec logutil-service /dev/shm/logs/certsync
|
if [[ "$(id -u)" -eq 0 ]]; then
|
||||||
|
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
|
||||||
|
exec logutil-service /dev/shm/logs/certsync
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Non-root (--user) fallback: logutil-service cannot change UID, so run
|
||||||
|
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/certsync
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
#!/command/with-contenv bash
|
#!/command/with-contenv bash
|
||||||
# shellcheck shell=bash
|
# shellcheck shell=bash
|
||||||
|
|
||||||
exec logutil-service /dev/shm/logs/frigate
|
if [[ "$(id -u)" -eq 0 ]]; then
|
||||||
|
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
|
||||||
|
exec logutil-service /dev/shm/logs/frigate
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Non-root (--user) fallback: logutil-service cannot change UID, so run
|
||||||
|
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/frigate
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
#!/command/with-contenv bash
|
#!/command/with-contenv bash
|
||||||
# shellcheck shell=bash
|
# shellcheck shell=bash
|
||||||
|
|
||||||
exec logutil-service /dev/shm/logs/go2rtc
|
if [[ "$(id -u)" -eq 0 ]]; then
|
||||||
|
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
|
||||||
|
exec logutil-service /dev/shm/logs/go2rtc
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Non-root (--user) fallback: logutil-service cannot change UID, so run
|
||||||
|
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/go2rtc
|
||||||
|
|||||||
@@ -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[@]}"
|
||||||
chown nobody:nogroup "${dirs[@]}"
|
|
||||||
|
# logutil-service drops s6-log to nobody, so the dirs must stay nobody-owned
|
||||||
|
# in root mode. Under docker --user we are already the (only) target user,
|
||||||
|
# chown would fail, and the plain s6-log fallback in the *-log services
|
||||||
|
# writes as us (the mkdir above is sufficient, /dev/shm is 1777).
|
||||||
|
if [[ "$(id -u)" -eq 0 ]]; then
|
||||||
|
chown nobody:nogroup "${dirs[@]}"
|
||||||
|
fi
|
||||||
chmod 02755 "${dirs[@]}"
|
chmod 02755 "${dirs[@]}"
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
#!/command/with-contenv bash
|
#!/command/with-contenv bash
|
||||||
# shellcheck shell=bash
|
# shellcheck shell=bash
|
||||||
|
|
||||||
exec logutil-service /dev/shm/logs/nginx
|
if [[ "$(id -u)" -eq 0 ]]; then
|
||||||
|
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
|
||||||
|
exec logutil-service /dev/shm/logs/nginx
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Non-root (--user) fallback: logutil-service cannot change UID, so run
|
||||||
|
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/nginx
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -75,6 +77,12 @@ http {
|
|||||||
vod_align_segments_to_key_frames on;
|
vod_align_segments_to_key_frames on;
|
||||||
vod_manifest_segment_durations_mode accurate;
|
vod_manifest_segment_durations_mode accurate;
|
||||||
vod_ignore_edit_list on;
|
vod_ignore_edit_list on;
|
||||||
|
# short leading segments at each playlist start; sources start at
|
||||||
|
# the seek target, so the ladder applies to every seek. Only
|
||||||
|
# effective when clips declare real keyFrameDurations
|
||||||
|
vod_bootstrap_segment_durations 1000;
|
||||||
|
vod_bootstrap_segment_durations 2000;
|
||||||
|
vod_bootstrap_segment_durations 4000;
|
||||||
vod_segment_duration 10000;
|
vod_segment_duration 10000;
|
||||||
|
|
||||||
# MPEG-TS settings (not used when fMP4 is enabled, kept for reference)
|
# MPEG-TS settings (not used when fMP4 is enabled, kept for reference)
|
||||||
@@ -117,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;
|
||||||
|
|
||||||
@@ -133,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;
|
||||||
|
|
||||||
@@ -154,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;
|
||||||
@@ -246,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/;
|
||||||
@@ -312,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.
|
||||||
@@ -253,16 +255,14 @@ birdseye:
|
|||||||
# 1 is the highest quality, and 31 is the lowest. Lower quality feeds utilize less CPU resources.
|
# 1 is the highest quality, and 31 is the lowest. Lower quality feeds utilize less CPU resources.
|
||||||
quality: 8
|
quality: 8
|
||||||
# Optional: Activity types that include cameras in Birdseye (default: shown below)
|
# Optional: Activity types that include cameras in Birdseye (default: shown below)
|
||||||
# Multiple activity types can be enabled at the same time.
|
# Multiple activity types can be listed at the same time.
|
||||||
mode:
|
# continuous: all cameras are included always
|
||||||
# Optional: All cameras are included always (default: shown below)
|
# motion: included if motion was detected within the inactivity threshold
|
||||||
continuous: False
|
# all_objects: included if a tracked object was present within the inactivity threshold
|
||||||
# Optional: Cameras are included if motion was detected in the last 30 seconds (default: shown below)
|
# alerts: included while an alert review item is in progress
|
||||||
motion: False
|
# detections: included while a detection review item is in progress
|
||||||
# Optional: Cameras are included if they have had an active tracked object within the last 30 seconds (default: shown below)
|
modes:
|
||||||
objects: True
|
- all_objects
|
||||||
# Optional: Cameras are included while they have a stationary tracked object (default: shown below)
|
|
||||||
stationary_objects: False
|
|
||||||
# Optional: Threshold for camera activity to stop showing camera (default: shown below)
|
# Optional: Threshold for camera activity to stop showing camera (default: shown below)
|
||||||
inactivity_threshold: 30
|
inactivity_threshold: 30
|
||||||
# Optional: Configure the birdseye layout
|
# Optional: Configure the birdseye layout
|
||||||
@@ -294,6 +294,8 @@ ffmpeg:
|
|||||||
detect: -threads 2 -f rawvideo -pix_fmt yuv420p
|
detect: -threads 2 -f rawvideo -pix_fmt yuv420p
|
||||||
# Optional: output args for record streams (default: shown below)
|
# Optional: output args for record streams (default: shown below)
|
||||||
record: preset-record-generic
|
record: preset-record-generic
|
||||||
|
# Optional: output args for sub stream record streams (default: the record output args above)
|
||||||
|
# record_sub: preset-record-generic
|
||||||
# Optional: Time in seconds to wait before ffmpeg retries connecting to the camera. (default: shown below)
|
# Optional: Time in seconds to wait before ffmpeg retries connecting to the camera. (default: shown below)
|
||||||
# If set too low, frigate will retry a connection to the camera's stream too frequently, using up the limited streams some cameras can allow at once
|
# If set too low, frigate will retry a connection to the camera's stream too frequently, using up the limited streams some cameras can allow at once
|
||||||
# If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage
|
# If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage
|
||||||
@@ -648,6 +650,42 @@ record:
|
|||||||
# For example, if the camera retain mode is "motion", the segments without motion are
|
# For example, if the camera retain mode is "motion", the segments without motion are
|
||||||
# never stored, so setting the mode to "all" here won't bring them back.
|
# never stored, so setting the mode to "all" here won't bring them back.
|
||||||
mode: motion
|
mode: motion
|
||||||
|
# Optional: Sub stream recording settings
|
||||||
|
# Records a second, lower quality stream for quality selection during playback
|
||||||
|
# and extended low quality retention. Requires the record_sub role to be assigned
|
||||||
|
# to one of the camera's inputs.
|
||||||
|
sub:
|
||||||
|
# Optional: Enable sub stream recording (default: shown below)
|
||||||
|
# NOTE: Recording must also be enabled for sub stream recording to run.
|
||||||
|
enabled: False
|
||||||
|
# Optional: Continuous retention settings for sub stream recordings
|
||||||
|
continuous:
|
||||||
|
# Optional: Number of days to retain sub stream recordings regardless of tracked objects or motion (default: shown below)
|
||||||
|
days: 0
|
||||||
|
# Optional: Motion retention settings for sub stream recordings
|
||||||
|
motion:
|
||||||
|
# Optional: Number of days to retain sub stream recordings triggered by motion (default: shown below)
|
||||||
|
days: 0
|
||||||
|
# Optional: Retention settings for sub stream recordings of alerts
|
||||||
|
# NOTE: Pre and post capture windows are taken from the main alerts config above.
|
||||||
|
alerts:
|
||||||
|
# Required: Retention days (default: shown below)
|
||||||
|
days: 10
|
||||||
|
# Optional: Mode for retention. (default: shown below)
|
||||||
|
# all - save all sub stream recording segments for alerts regardless of activity
|
||||||
|
# motion - save all sub stream recording segments for alerts with any detected motion
|
||||||
|
# active_objects - save all sub stream recording segments for alerts with active/moving objects
|
||||||
|
mode: motion
|
||||||
|
# Optional: Retention settings for sub stream recordings of detections
|
||||||
|
# NOTE: Pre and post capture windows are taken from the main detections config above.
|
||||||
|
detections:
|
||||||
|
# Required: Retention days (default: shown below)
|
||||||
|
days: 10
|
||||||
|
# Optional: Mode for retention. (default: shown below)
|
||||||
|
# all - save all sub stream recording segments for detections regardless of activity
|
||||||
|
# motion - save all sub stream recording segments for detections with any detected motion
|
||||||
|
# active_objects - save all sub stream recording segments for detections with active/moving objects
|
||||||
|
mode: motion
|
||||||
|
|
||||||
# Optional: Configuration for the snapshots written to the clips directory for each tracked object
|
# Optional: Configuration for the snapshots written to the clips directory for each tracked object
|
||||||
# Timestamp, bounding_box, crop and height settings are applied by default to API requests for snapshots.
|
# Timestamp, bounding_box, crop and height settings are applied by default to API requests for snapshots.
|
||||||
@@ -899,7 +937,7 @@ cameras:
|
|||||||
# Required: the path to the stream
|
# Required: the path to the stream
|
||||||
# NOTE: path may include environment variables or docker secrets, which must begin with 'FRIGATE_' and be referenced in {}
|
# NOTE: path may include environment variables or docker secrets, which must begin with 'FRIGATE_' and be referenced in {}
|
||||||
- path: rtsp://viewer:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
|
- path: rtsp://viewer:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
|
||||||
# Required: list of roles for this stream. valid values are: audio,detect,record
|
# Required: list of roles for this stream. valid values are: audio,detect,record,record_sub
|
||||||
# NOTICE: In addition to assigning the audio, detect, and record roles
|
# NOTICE: In addition to assigning the audio, detect, and record roles
|
||||||
# they must also be enabled in the camera config.
|
# they must also be enabled in the camera config.
|
||||||
roles:
|
roles:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ Each camera tile in Birdseye is composed from the frames of the stream assigned
|
|||||||
|
|
||||||
### Birdseye Activity Types
|
### Birdseye Activity Types
|
||||||
|
|
||||||
Birdseye offers independent activity types that control when cameras are shown. Multiple activity types can be enabled together.
|
Birdseye offers independent activity types that control when cameras are shown. Multiple activity types can be listed together.
|
||||||
|
|
||||||
- **continuous:** The camera is always included
|
- **continuous:** The camera is always included
|
||||||
- **motion:** The camera is included when motion was detected within the last 30 seconds
|
- **motion:** The camera is included when motion was detected within the last 30 seconds
|
||||||
- **objects:** The camera is included when an active object was tracked within the last 30 seconds
|
- **all_objects:** The camera is included when a tracked object is present, active or stationary
|
||||||
- **stationary_objects:** The camera is included while a stationary object is tracked
|
- **alerts:** The camera is included while an alert review item is in progress
|
||||||
|
- **detections:** The camera is included while a detection review item is in progress
|
||||||
|
|
||||||
|
`alerts` and `detections` follow the review item's own lifetime, so the camera is removed as soon as the review item ends. Which objects qualify for each is set in [review configuration](./review.md).
|
||||||
|
|
||||||
### Custom Birdseye Icon
|
### Custom Birdseye Icon
|
||||||
|
|
||||||
@@ -50,20 +53,19 @@ To include a camera in Birdseye view only for specific circumstances, or exclude
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
|
|
||||||
```yaml {8-11,13-15}
|
```yaml {10-12,15-16}
|
||||||
# Include all cameras by default in Birdseye view
|
# Include all cameras by default in Birdseye view
|
||||||
birdseye:
|
birdseye:
|
||||||
enabled: True
|
enabled: True
|
||||||
mode:
|
modes:
|
||||||
continuous: True
|
- continuous
|
||||||
|
|
||||||
cameras:
|
cameras:
|
||||||
front:
|
front:
|
||||||
# Only include the "front" camera in Birdseye view when objects are detected
|
# Only include the "front" camera in Birdseye view when an alert is in progress
|
||||||
birdseye:
|
birdseye:
|
||||||
mode:
|
modes:
|
||||||
continuous: False
|
- alerts
|
||||||
objects: True
|
|
||||||
back:
|
back:
|
||||||
# Exclude the "back" camera from Birdseye view
|
# Exclude the "back" camera from Birdseye view
|
||||||
birdseye:
|
birdseye:
|
||||||
@@ -75,7 +77,7 @@ cameras:
|
|||||||
|
|
||||||
### Birdseye Inactivity
|
### Birdseye Inactivity
|
||||||
|
|
||||||
By default birdseye shows all cameras that have had the configured activity in the last 30 seconds. This threshold can be configured.
|
By default birdseye shows all cameras that have had the configured activity in the last 30 seconds. This threshold can be configured, and applies to the `motion` and `all_objects` activity types only.
|
||||||
|
|
||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
@@ -144,7 +146,8 @@ Navigate to <NavPath path="Settings > System > Birdseye" /> and in the **Camera
|
|||||||
# Include all cameras by default in Birdseye view
|
# Include all cameras by default in Birdseye view
|
||||||
birdseye:
|
birdseye:
|
||||||
enabled: True
|
enabled: True
|
||||||
mode: continuous
|
modes:
|
||||||
|
- continuous
|
||||||
|
|
||||||
cameras:
|
cameras:
|
||||||
front:
|
front:
|
||||||
|
|||||||
@@ -83,11 +83,12 @@ 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) |
|
||||||
| `audio` | Feed for audio based detection. [docs](audio_detectors.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) |
|
||||||
|
|
||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -106,3 +106,5 @@ Output arguments are passed to FFmpeg after your camera source and control how r
|
|||||||
| preset-record-mjpeg | Record - MJPEG Cameras | Record an MJPEG stream | Restreaming the MJPEG stream is recommended instead |
|
| preset-record-mjpeg | Record - MJPEG Cameras | Record an MJPEG stream | Restreaming the MJPEG stream is recommended instead |
|
||||||
| preset-record-jpeg | Record - JPEG Cameras | Record a live JPEG | Restreaming the live JPEG is recommended instead |
|
| preset-record-jpeg | Record - JPEG Cameras | Record a live JPEG | Restreaming the live JPEG is recommended instead |
|
||||||
| preset-record-ubiquiti | Record - Ubiquiti Cameras | Record a Ubiquiti stream with audio | Handles Ubiquiti's non-standard audio format |
|
| preset-record-ubiquiti | Record - Ubiquiti Cameras | Record a Ubiquiti stream with audio | Handles Ubiquiti's non-standard audio format |
|
||||||
|
|
||||||
|
These presets apply to the `record` output args. If [sub stream recording](/configuration/record#sub-stream-recording) is enabled, the same args are used for the `record_sub` role unless `output_args.record_sub` is set, which accepts the same presets and manual args.
|
||||||
|
|||||||
@@ -59,13 +59,17 @@ 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -275,6 +275,163 @@ record:
|
|||||||
|
|
||||||
This configuration will retain recording segments that overlap with alerts and detections for 10 days. Because multiple tracked objects can reference the same recording segments, this avoids storing duplicate footage for overlapping tracked objects and reduces overall storage needs.
|
This configuration will retain recording segments that overlap with alerts and detections for 10 days. Because multiple tracked objects can reference the same recording segments, this avoids storing duplicate footage for overlapping tracked objects and reduces overall storage needs.
|
||||||
|
|
||||||
|
## Sub Stream Recording
|
||||||
|
|
||||||
|
In addition to the main recording stream, Frigate can record a second, lower quality stream for each camera. This serves two purposes:
|
||||||
|
|
||||||
|
- **Quality selection during playback**: A quality selector (`Auto`, `Original`, or `Low`) appears in History view for cameras with sub stream recording enabled. `Original` and `Low` play only that stream's recordings. Time ranges where the selected stream has no footage are skipped during playback, and the selector notes when the selected stream has no recordings at all in the viewed time range. With `Auto` (the default), playback prefers the original quality and automatically falls back to the low quality stream when the connection cannot keep up, or for time ranges where the original recordings have expired. The selector shows each stream's video codec and audio details beneath the options; footage recorded by older Frigate versions shows no details.
|
||||||
|
- **Extended retention**: Sub stream recordings have their own retention settings, fully independent of the main recordings. By giving the low quality recordings a longer retention period, you can keep weeks or months of low quality history using a fraction of the storage, and that history remains playable after the main recordings expire. Playback falls back to the low quality recordings automatically, and the timeline shows a muted treatment for time ranges where only low quality footage remains. Timeline previews are kept for as long as either stream still has recordings, so scrubbing works across the whole retained history.
|
||||||
|
|
||||||
|
### Configuring sub stream recording
|
||||||
|
|
||||||
|
Sub stream recording uses the `record_sub` input role. This role can be assigned to the same input as `detect`, so in the common case where detect already uses the camera's sub stream, no additional camera connection is needed. Like the main recording stream, sub stream segments are copied directly from the camera stream without re-encoding, so the recording quality is determined by the source stream.
|
||||||
|
|
||||||
|
The following examples keep 7 days of full quality continuous recordings and 60 days of low quality continuous recordings:
|
||||||
|
|
||||||
|
<ConfigTabs>
|
||||||
|
<TabItem value="ui">
|
||||||
|
|
||||||
|
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and select the camera.
|
||||||
|
|
||||||
|
- In **Camera inputs**, enable the **Record (Sub Stream)** role on the stream you want to record at low quality, commonly the same stream that has the **Detect** role. Only one stream may have this role, and it cannot be assigned to the same stream as the **Record** role.
|
||||||
|
|
||||||
|
Navigate to <NavPath path="Settings > Camera configuration > Recording" /> and select the camera.
|
||||||
|
|
||||||
|
- Set **Enable recording** to on
|
||||||
|
- Set **Continuous retention > Retention days** to `7`
|
||||||
|
- Set **Sub stream recording > Enable sub stream recording** to on
|
||||||
|
- Set **Sub stream recording > Sub stream continuous retention > Retention days** to `60`
|
||||||
|
|
||||||
|
The camera setup wizard also offers the **Record (Sub Stream)** role when assigning stream roles for a newly added camera.
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem value="yaml">
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
front_door:
|
||||||
|
ffmpeg:
|
||||||
|
inputs:
|
||||||
|
- path: rtsp://camera/main
|
||||||
|
roles:
|
||||||
|
- record
|
||||||
|
- path: rtsp://camera/sub
|
||||||
|
roles:
|
||||||
|
- detect
|
||||||
|
- record_sub
|
||||||
|
record:
|
||||||
|
enabled: true
|
||||||
|
continuous:
|
||||||
|
days: 7
|
||||||
|
sub:
|
||||||
|
enabled: true
|
||||||
|
continuous:
|
||||||
|
days: 60
|
||||||
|
```
|
||||||
|
|
||||||
|
If your camera does not provide a suitable sub stream (or the sub stream is already used at a resolution you don't want to record), you can use a go2rtc transcode as the source for `record_sub` instead:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
go2rtc:
|
||||||
|
streams:
|
||||||
|
front_door: rtsp://camera/main
|
||||||
|
front_door_lq: ffmpeg:front_door#video=h264#width=854#hardware
|
||||||
|
|
||||||
|
cameras:
|
||||||
|
front_door:
|
||||||
|
ffmpeg:
|
||||||
|
inputs:
|
||||||
|
- path: rtsp://127.0.0.1:8554/front_door
|
||||||
|
input_args: preset-rtsp-restream
|
||||||
|
roles:
|
||||||
|
- detect
|
||||||
|
- record
|
||||||
|
- path: rtsp://127.0.0.1:8554/front_door_lq
|
||||||
|
input_args: preset-rtsp-restream
|
||||||
|
roles:
|
||||||
|
- record_sub
|
||||||
|
record:
|
||||||
|
enabled: true
|
||||||
|
continuous:
|
||||||
|
days: 7
|
||||||
|
sub:
|
||||||
|
enabled: true
|
||||||
|
continuous:
|
||||||
|
days: 60
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</ConfigTabs>
|
||||||
|
|
||||||
|
The `record.sub` config supports the same retention structure as the main recording config: `continuous`, `motion`, `alerts`, and `detections` each with their own `days` (and `mode` for alerts and detections). The pre-capture and post-capture windows for alerts and detections are taken from the main `record.alerts` and `record.detections` config. Extending `sub.alerts.days` or `sub.detections.days` beyond the main values also keeps those review items visible in the review timeline for the longer window, with playback falling back to the low quality stream once the main recordings expire.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
|
||||||
|
Recording must be enabled (`record.enabled`) for sub stream recording to run, and Frigate will fail to start if `record.sub.enabled` is set without a `record_sub` role assigned to one of the camera's inputs.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
### How Auto picks a quality
|
||||||
|
|
||||||
|
`Auto` measures throughput on every segment download and compares it against the original stream's bitrate (computed from the recorded footage itself). Playback drops to the low quality stream when any of these happen:
|
||||||
|
|
||||||
|
- A freeze lasts 4 seconds (10 seconds when it starts within 2 seconds of a seek, since the seek target is rarely buffered), or freezes total 7 seconds within the last minute.
|
||||||
|
- 3 downloads in a row measure below the original bitrate plus 10%, dropping quality before a stall ever becomes visible.
|
||||||
|
- No first frame appears within 10 seconds, or loading fails outright.
|
||||||
|
|
||||||
|
Playback returns to full quality only when measured throughput exceeds the original bitrate by 50%, checked continuously while playing the low quality stream and again at each new hour. The asymmetric thresholds (1.1x to drop, 1.5x to return) keep a borderline connection from switching back and forth.
|
||||||
|
|
||||||
|
The most recent measurement is remembered on the device: a connection last measured below the original bitrate (or below 3 Mbps when the bitrate is not yet known) starts playback on the low quality stream so a first frame appears immediately, then upgrades within a few segments if the speed allows.
|
||||||
|
|
||||||
|
The quality selector shows which stream Auto is currently playing and why. A browser with Data Saver enabled stays on the low quality stream, a browser that cannot decode the original stream's codec (for example H.265 without HEVC support) plays the low quality stream for that camera, and pinning `Original` or `Low` bypasses Auto entirely.
|
||||||
|
|
||||||
|
### Sub stream output args
|
||||||
|
|
||||||
|
By default the sub stream is recorded with the same [output args](/configuration/ffmpeg_presets#output-args-presets) as the main recording stream, so it inherits any customization made to `ffmpeg.output_args.record`. Setting `ffmpeg.output_args.record_sub` gives the sub stream its own args instead. Like all `ffmpeg` config, this can be set globally or per camera.
|
||||||
|
|
||||||
|
The most common reason to set this is a pair of streams whose audio differs. Many cameras send AAC on the main stream but PCM on the sub stream, and PCM cannot be copied into an mp4 recording. Copying the main stream's audio avoids re-encoding audio that is already AAC, while the sub stream still needs to be transcoded:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ffmpeg:
|
||||||
|
output_args:
|
||||||
|
# main stream audio is already AAC, so copy it
|
||||||
|
record: preset-record-generic-audio-copy
|
||||||
|
# sub stream audio is PCM, so transcode it to AAC
|
||||||
|
record_sub: preset-record-generic-audio-aac
|
||||||
|
```
|
||||||
|
|
||||||
|
Other reasons to set this are recording a sub stream whose codec needs a different preset than the main stream, such as `preset-record-mjpeg`, or forcing a matching audio sample rate across the two streams with manual args ending in `-c:a aac -ar 16000`.
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
|
||||||
|
Avoid removing audio from only one of the two streams (for example with `-an`). When one stream has audio and the other does not, playback of time ranges that combine both qualities is silent, so stripping audio from the sub stream also silences the merged timeline.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Which stream do features use?
|
||||||
|
|
||||||
|
As a general rule, features that read recordings prefer the main stream and fall back to the sub stream for time ranges where the main recordings have expired. Analytics features use only the main stream.
|
||||||
|
|
||||||
|
| Feature | Stream used |
|
||||||
|
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Recording playback (History and Review) | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected manually |
|
||||||
|
| Tracking details and Explore clip playback | Main, falling back to sub where the main recordings have expired |
|
||||||
|
| Exports and clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
|
||||||
|
| Frames grabbed from a recording in History (download snapshot, submit frame to Frigate+) | Main preferred, sub fallback |
|
||||||
|
| Audio extraction (e.g., transcription) | Main preferred, sub fallback |
|
||||||
|
| Motion search | Main only |
|
||||||
|
| Review timeline motion data | Main only |
|
||||||
|
| Storage usage statistics | Both streams counted, and listed separately per camera |
|
||||||
|
|
||||||
|
This table covers only features that read recordings from disk. Tracked object snapshots and thumbnails (the images shown in Explore and sent with notifications, and the images submitted to Frigate+ from a tracked object) are captured live from the `detect` stream as the object is tracked, never from recordings, so sub stream recording does not affect them.
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
|
||||||
|
- Recording a second stream increases overall storage use. The increase is typically small relative to the main recordings, since the low quality stream is much smaller. Both streams are cached before being written to disk, so cache use goes up as well. See [the `/tmp/cache` area is separate](#the-tmpcache-area-is-separate) if you start seeing `No space left on device` errors after enabling it.
|
||||||
|
- The go2rtc transcode approach continuously encodes the low quality stream, which uses CPU or GPU resources. This cost only applies to the transcode path; recording the camera's native sub stream does not re-encode. See the [go2rtc hardware acceleration documentation](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) for accelerating the transcode.
|
||||||
|
- Many camera sub streams do not include audio. If the source stream has no audio, the low quality recordings will not have audio.
|
||||||
|
- **Matching video codecs and audio settings between the two streams gives the smoothest playback.** When playback combines both qualities on one timeline (the default `Auto` behavior: for example original quality during events with low quality in between, or low quality history after the original recordings expire) and the streams use different video codecs or audio settings, for example H.265 on the main stream and H.264 on the sub stream, or 16 kHz audio on one and 8 kHz on the other, playback still works: Frigate inserts a decoder reset at each quality transition, which can cause a barely-perceptible pause there. Configuring both streams in the camera's firmware to use the same video codec, audio codec, and sample rate makes transitions fully seamless, and a mismatched audio sample rate can also be corrected with [sub stream output args](#sub-stream-output-args). If one stream has audio and the other does not, combined time ranges play **without audio**; selecting a single quality with the playback selector always keeps that stream's audio.
|
||||||
|
|
||||||
## Can I have "continuous" recordings, but only at certain times?
|
## Can I have "continuous" recordings, but only at certain times?
|
||||||
|
|
||||||
Using Frigate UI, Home Assistant, or MQTT, cameras can be automated to only record in certain situations or at certain times.
|
Using Frigate UI, Home Assistant, or MQTT, cameras can be automated to only record in certain situations or at certain times.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -553,23 +553,25 @@ must be enabled in the configuration.
|
|||||||
|
|
||||||
Topic with current state of Birdseye for a camera. Published values are `ON` and `OFF`.
|
Topic with current state of Birdseye for a camera. Published values are `ON` and `OFF`.
|
||||||
|
|
||||||
### `frigate/<camera_name>/birdseye_mode/set`
|
### `frigate/<camera_name>/birdseye_modes/set`
|
||||||
|
|
||||||
Topic to set the Birdseye activity types for a camera. Send one uppercase activity type or combine multiple types with commas, for example `MOTION,OBJECTS,STATIONARY_OBJECTS`.
|
Topic to set the Birdseye activity types for a camera. Send one uppercase activity type or combine multiple types with commas, for example `MOTION,ALERTS`.
|
||||||
|
|
||||||
_Note: Changing the value from `CONTINUOUS` to non-continuous activity types will take up to 30 seconds for
|
_Note: Changing the value from `CONTINUOUS` to non-continuous activity types will take up to 30 seconds for
|
||||||
the camera to be removed from the view._
|
the camera to be removed from the view._
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| -------------------- | ---------------------------------------------------------------- |
|
| ------------- | ---------------------------------------------------------------- |
|
||||||
| `CONTINUOUS` | Always included |
|
| `CONTINUOUS` | Always included |
|
||||||
| `MOTION` | Shown if motion was detected within the last 30 seconds |
|
| `MOTION` | Shown if motion was detected within the last 30 seconds |
|
||||||
| `OBJECTS` | Shown if an active object was tracked within the last 30 seconds |
|
| `ALL_OBJECTS` | Shown if a tracked object was present within the last 30 seconds |
|
||||||
| `STATIONARY_OBJECTS` | Shown while a stationary object is tracked |
|
| `ALERTS` | Shown while an alert review item is in progress |
|
||||||
|
| `DETECTIONS` | Shown while a detection review item is in progress |
|
||||||
|
| `NONE` | Never included |
|
||||||
|
|
||||||
### `frigate/<camera_name>/birdseye_mode/state`
|
### `frigate/<camera_name>/birdseye_modes/state`
|
||||||
|
|
||||||
Topic with the current Birdseye activity types for a camera. Multiple enabled types are published as a comma-separated value in the order `OBJECTS`, `MOTION`, `STATIONARY_OBJECTS`, `CONTINUOUS`.
|
Topic with the current Birdseye activity types for a camera. Multiple enabled types are published as a comma-separated value in the order `CONTINUOUS`, `MOTION`, `ALL_OBJECTS`, `ALERTS`, `DETECTIONS`. `NONE` is published when no activity types are enabled.
|
||||||
|
|
||||||
### `frigate/<camera_name>/notifications/set`
|
### `frigate/<camera_name>/notifications/set`
|
||||||
|
|
||||||
|
|||||||
@@ -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
+243
-11
@@ -713,7 +713,7 @@ paths:
|
|||||||
| `improve_contrast` | `ON`, `OFF` |
|
| `improve_contrast` | `ON`, `OFF` |
|
||||||
| `ptz_autotracker` | `ON`, `OFF` |
|
| `ptz_autotracker` | `ON`, `OFF` |
|
||||||
| `birdseye` | `ON`, `OFF` |
|
| `birdseye` | `ON`, `OFF` |
|
||||||
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
|
| `birdseye_modes` | `CONTINUOUS`, `MOTION`, `ALL_OBJECTS`, `ALERTS`, `DETECTIONS`, `NONE`, or a comma-separated combination |
|
||||||
| `motion_contour_area` | integer |
|
| `motion_contour_area` | integer |
|
||||||
| `motion_threshold` | integer |
|
| `motion_threshold` | integer |
|
||||||
| `motion_mask` | `ON`, `OFF` |
|
| `motion_mask` | `ON`, `OFF` |
|
||||||
@@ -803,7 +803,7 @@ paths:
|
|||||||
| `improve_contrast` | `ON`, `OFF` |
|
| `improve_contrast` | `ON`, `OFF` |
|
||||||
| `ptz_autotracker` | `ON`, `OFF` |
|
| `ptz_autotracker` | `ON`, `OFF` |
|
||||||
| `birdseye` | `ON`, `OFF` |
|
| `birdseye` | `ON`, `OFF` |
|
||||||
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
|
| `birdseye_modes` | `CONTINUOUS`, `MOTION`, `ALL_OBJECTS`, `ALERTS`, `DETECTIONS`, `NONE`, or a comma-separated combination |
|
||||||
| `motion_contour_area` | integer |
|
| `motion_contour_area` | integer |
|
||||||
| `motion_threshold` | integer |
|
| `motion_threshold` | integer |
|
||||||
| `motion_mask` | `ON`, `OFF` |
|
| `motion_mask` | `ON`, `OFF` |
|
||||||
@@ -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:
|
||||||
@@ -6022,6 +6067,65 @@ paths:
|
|||||||
security:
|
security:
|
||||||
- frigateUserAuth: []
|
- frigateUserAuth: []
|
||||||
x-required-role: camera
|
x-required-role: camera
|
||||||
|
/vod/{camera_name}/{stream}/start/{start_ts}/end/{end_ts}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Media
|
||||||
|
summary: Vod Ts Stream
|
||||||
|
description: |-
|
||||||
|
**Access:** Authenticated user with access to the referenced camera.
|
||||||
|
|
||||||
|
Returns an HLS playlist pinned to one stream type (main or sub) for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.
|
||||||
|
operationId:
|
||||||
|
vod_ts_stream_vod__camera_name___stream__start__start_ts__end__end_ts__get
|
||||||
|
parameters:
|
||||||
|
- name: camera_name
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: 'null'
|
||||||
|
title: Camera Name
|
||||||
|
- name: stream
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/VodStreamPreference'
|
||||||
|
- name: start_ts
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
title: Start Ts
|
||||||
|
- name: end_ts
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
title: End Ts
|
||||||
|
- name: force_discontinuity
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
title: Force Discontinuity
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {}
|
||||||
|
'422':
|
||||||
|
description: Validation Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
|
security:
|
||||||
|
- frigateUserAuth: []
|
||||||
|
x-required-role: camera
|
||||||
/events/{event_id}/snapshot.jpg:
|
/events/{event_id}/snapshot.jpg:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -6960,6 +7064,63 @@ paths:
|
|||||||
security:
|
security:
|
||||||
- frigateUserAuth: []
|
- frigateUserAuth: []
|
||||||
x-required-role: camera
|
x-required-role: camera
|
||||||
|
/{camera_name}/recordings/coverage:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Recordings
|
||||||
|
summary: Recordings Coverage
|
||||||
|
description: |-
|
||||||
|
**Access:** Authenticated user with access to the referenced camera.
|
||||||
|
|
||||||
|
Returns merged recording coverage spans plus codec compatibility.
|
||||||
|
|
||||||
|
codecs_compatible is false only when more than one known video codec
|
||||||
|
appears across the range's rows, the case where the merged vod route
|
||||||
|
degrades to a single-stream manifest.
|
||||||
|
operationId: recordings_coverage__camera_name__recordings_coverage_get
|
||||||
|
parameters:
|
||||||
|
- name: camera_name
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: 'null'
|
||||||
|
title: Camera Name
|
||||||
|
- name: after
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
title: After
|
||||||
|
- name: before
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
title: Before
|
||||||
|
- name: timelines
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
title: Timelines
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {}
|
||||||
|
'422':
|
||||||
|
description: Validation Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
|
security:
|
||||||
|
- frigateUserAuth: []
|
||||||
|
x-required-role: camera
|
||||||
/{camera_name}/recordings:
|
/{camera_name}/recordings:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -7147,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':
|
||||||
@@ -7724,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:
|
||||||
@@ -8451,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:
|
||||||
@@ -8941,6 +9162,17 @@ components:
|
|||||||
- msg
|
- msg
|
||||||
- type
|
- type
|
||||||
title: ValidationError
|
title: ValidationError
|
||||||
|
VodStreamPreference:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- main
|
||||||
|
- sub
|
||||||
|
title: VodStreamPreference
|
||||||
|
description: |-
|
||||||
|
Stream pin for the path-segment VOD route.
|
||||||
|
|
||||||
|
nginx-vod derives its mapping fetch URI from the playlist URL path
|
||||||
|
(query params are dropped), so the preference must be a path segment.
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
frigateAdminAuth:
|
frigateAdminAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
|
|||||||
+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",
|
||||||
|
)
|
||||||
|
|||||||
+40
-3
@@ -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):
|
||||||
@@ -651,6 +651,32 @@ async def _connect_onvif_camera(
|
|||||||
raise first_error
|
raise first_error
|
||||||
|
|
||||||
|
|
||||||
|
def _supports_continuous_pan_tilt(nodes) -> bool:
|
||||||
|
"""Whether any PTZ node advertises continuous pan/tilt velocity.
|
||||||
|
|
||||||
|
The web UI's directional controls issue ContinuousMove with a PanTilt
|
||||||
|
velocity, so continuous pan/tilt is what makes those controls usable. This
|
||||||
|
is intentionally narrower than ptz_supported, which is true for any device
|
||||||
|
exposing the ONVIF PTZ service - including zoom/focus-only varifocal lenses.
|
||||||
|
"""
|
||||||
|
for node in nodes or []:
|
||||||
|
spaces = getattr(node, "SupportedPTZSpaces", None) or (
|
||||||
|
node.get("SupportedPTZSpaces") if isinstance(node, dict) else None
|
||||||
|
)
|
||||||
|
if spaces is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
continuous = getattr(spaces, "ContinuousPanTiltVelocitySpace", None) or (
|
||||||
|
spaces.get("ContinuousPanTiltVelocitySpace")
|
||||||
|
if isinstance(spaces, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if continuous:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/onvif/probe",
|
"/onvif/probe",
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
@@ -808,6 +834,7 @@ async def onvif_probe(
|
|||||||
|
|
||||||
# Check PTZ support and capabilities
|
# Check PTZ support and capabilities
|
||||||
ptz_supported = False
|
ptz_supported = False
|
||||||
|
pan_tilt_supported = False
|
||||||
presets_count = 0
|
presets_count = 0
|
||||||
autotrack_supported = False
|
autotrack_supported = False
|
||||||
|
|
||||||
@@ -841,6 +868,15 @@ async def onvif_probe(
|
|||||||
logger.debug(f"Failed to get presets: {e}")
|
logger.debug(f"Failed to get presets: {e}")
|
||||||
presets_count = 0
|
presets_count = 0
|
||||||
|
|
||||||
|
# Check for real (continuous) pan/tilt, which the UI controls need
|
||||||
|
if ptz_supported:
|
||||||
|
try:
|
||||||
|
nodes = await ptz_service.GetNodes()
|
||||||
|
pan_tilt_supported = _supports_continuous_pan_tilt(nodes)
|
||||||
|
logger.debug(f"Continuous pan/tilt supported: {pan_tilt_supported}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to read PTZ nodes for pan/tilt support: {e}")
|
||||||
|
|
||||||
# Check for autotracking support - requires both FOV relative movement and MoveStatus
|
# Check for autotracking support - requires both FOV relative movement and MoveStatus
|
||||||
if ptz_supported and first_profile_token and ptz_config_token:
|
if ptz_supported and first_profile_token and ptz_config_token:
|
||||||
# First check for FOV relative movement support
|
# First check for FOV relative movement support
|
||||||
@@ -960,6 +996,7 @@ async def onvif_probe(
|
|||||||
"firmware_version": device_info["firmware_version"],
|
"firmware_version": device_info["firmware_version"],
|
||||||
"profiles_count": profiles_count,
|
"profiles_count": profiles_count,
|
||||||
"ptz_supported": ptz_supported,
|
"ptz_supported": ptz_supported,
|
||||||
|
"pan_tilt_supported": pan_tilt_supported,
|
||||||
"presets_count": presets_count,
|
"presets_count": presets_count,
|
||||||
"autotrack_supported": autotrack_supported,
|
"autotrack_supported": autotrack_supported,
|
||||||
}
|
}
|
||||||
@@ -1349,7 +1386,7 @@ def camera_set(
|
|||||||
| `improve_contrast` | `ON`, `OFF` |
|
| `improve_contrast` | `ON`, `OFF` |
|
||||||
| `ptz_autotracker` | `ON`, `OFF` |
|
| `ptz_autotracker` | `ON`, `OFF` |
|
||||||
| `birdseye` | `ON`, `OFF` |
|
| `birdseye` | `ON`, `OFF` |
|
||||||
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
|
| `birdseye_modes` | `CONTINUOUS`, `MOTION`, `ALL_OBJECTS`, `ALERTS`, `DETECTIONS`, `NONE`, or a comma-separated combination |
|
||||||
| `motion_contour_area` | integer |
|
| `motion_contour_area` | integer |
|
||||||
| `motion_threshold` | integer |
|
| `motion_threshold` | integer |
|
||||||
| `motion_mask` | `ON`, `OFF` |
|
| `motion_mask` | `ON`, `OFF` |
|
||||||
|
|||||||
+2
-2
@@ -597,7 +597,7 @@ async def _execute_get_live_context(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
frame_processor = request.app.detected_frames_processor
|
frame_processor = request.app.detected_frames_processor
|
||||||
camera_state = frame_processor.camera_states.get(camera)
|
camera_state = frame_processor.get_camera_state(camera)
|
||||||
|
|
||||||
if camera_state is None:
|
if camera_state is None:
|
||||||
return {
|
return {
|
||||||
@@ -661,7 +661,7 @@ async def _get_live_frame_image_url(
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
frame_processor = request.app.detected_frames_processor
|
frame_processor = request.app.detected_frames_processor
|
||||||
if camera not in frame_processor.camera_states:
|
if frame_processor.get_camera_state(camera) is None:
|
||||||
return None
|
return None
|
||||||
frame = frame_processor.get_current_frame(camera, {})
|
frame = frame_processor.get_current_frame(camera, {})
|
||||||
if frame is None:
|
if frame is None:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1313,7 +1313,7 @@ async def set_sub_label(
|
|||||||
if request.app.detected_frames_processor:
|
if request.app.detected_frames_processor:
|
||||||
tracked_obj: TrackedObject = None
|
tracked_obj: TrackedObject = None
|
||||||
|
|
||||||
for state in request.app.detected_frames_processor.camera_states.values():
|
for state in request.app.detected_frames_processor.get_camera_states():
|
||||||
tracked_obj = state.tracked_objects.get(event_id)
|
tracked_obj = state.tracked_objects.get(event_id)
|
||||||
|
|
||||||
if tracked_obj is not None:
|
if tracked_obj is not None:
|
||||||
@@ -1372,7 +1372,7 @@ async def set_plate(
|
|||||||
if request.app.detected_frames_processor:
|
if request.app.detected_frames_processor:
|
||||||
tracked_obj: TrackedObject = None
|
tracked_obj: TrackedObject = None
|
||||||
|
|
||||||
for state in request.app.detected_frames_processor.camera_states.values():
|
for state in request.app.detected_frames_processor.get_camera_states():
|
||||||
tracked_obj = state.tracked_objects.get(event_id)
|
tracked_obj = state.tracked_objects.get(event_id)
|
||||||
|
|
||||||
if tracked_obj is not None:
|
if tracked_obj is not None:
|
||||||
|
|||||||
@@ -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)
|
||||||
+316
-151
@@ -6,10 +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 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
|
||||||
@@ -39,12 +42,14 @@ from frigate.config.camera.snapshots import SnapshotsConfig
|
|||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
INSTALL_DIR,
|
INSTALL_DIR,
|
||||||
MAX_SEGMENT_DURATION,
|
|
||||||
PREVIEW_FRAME_TYPE,
|
PREVIEW_FRAME_TYPE,
|
||||||
|
STREAM_TYPE_MAIN,
|
||||||
|
STREAM_TYPE_SUB,
|
||||||
)
|
)
|
||||||
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,
|
||||||
@@ -52,12 +57,40 @@ from frigate.util.file import (
|
|||||||
load_event_snapshot_image,
|
load_event_snapshot_image,
|
||||||
)
|
)
|
||||||
from frigate.util.image import get_image_from_recording, get_image_quality_params
|
from frigate.util.image import get_image_from_recording, get_image_quality_params
|
||||||
from frigate.util.media import get_keyframe_before
|
|
||||||
from frigate.util.object import create_empty_regions_grid
|
from frigate.util.object import create_empty_regions_grid
|
||||||
|
from frigate.util.recording_coverage import (
|
||||||
|
build_spans,
|
||||||
|
null_audio_glitches,
|
||||||
|
plan_clip,
|
||||||
|
resolve_coverage,
|
||||||
|
stream_has_audio,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# must match the patched MAX_CLIPS in docker/main/build_nginx.sh; a
|
||||||
|
# normal hour needs ~360, one clip per recording file
|
||||||
|
NGINX_VOD_MAX_CLIPS = 1080
|
||||||
|
|
||||||
|
# tail of ffmpeg's stderr kept for the clip download failure log
|
||||||
|
CLIP_STDERR_LOG_BYTES = 8192
|
||||||
|
|
||||||
|
# how long a drained clip download waits for ffmpeg to exit on its own
|
||||||
|
CLIP_FFMPEG_EXIT_TIMEOUT = 10
|
||||||
|
|
||||||
|
|
||||||
|
class VodStreamPreference(str, Enum):
|
||||||
|
"""Stream pin for the path-segment VOD route.
|
||||||
|
|
||||||
|
nginx-vod derives its mapping fetch URI from the playlist URL path
|
||||||
|
(query params are dropped), so the preference must be a path segment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
main = STREAM_TYPE_MAIN
|
||||||
|
sub = STREAM_TYPE_SUB
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=[Tags.media])
|
router = APIRouter(tags=[Tags.media])
|
||||||
|
|
||||||
|
|
||||||
@@ -319,7 +352,7 @@ async def get_snapshot_from_recording(
|
|||||||
& (frame_time <= Recordings.end_time)
|
& (frame_time <= Recordings.end_time)
|
||||||
)
|
)
|
||||||
.where(Recordings.camera == camera_name)
|
.where(Recordings.camera == camera_name)
|
||||||
.order_by(Recordings.start_time.desc())
|
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
)
|
)
|
||||||
@@ -338,7 +371,7 @@ async def get_snapshot_from_recording(
|
|||||||
& (frame_time <= Recordings.end_time)
|
& (frame_time <= Recordings.end_time)
|
||||||
)
|
)
|
||||||
.where(Recordings.camera == camera_name)
|
.where(Recordings.camera == camera_name)
|
||||||
.order_by(Recordings.start_time.desc())
|
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
)
|
)
|
||||||
@@ -398,7 +431,7 @@ async def submit_recording_snapshot_to_plus(
|
|||||||
(frame_time >= Recordings.start_time) & (frame_time <= Recordings.end_time)
|
(frame_time >= Recordings.start_time) & (frame_time <= Recordings.end_time)
|
||||||
)
|
)
|
||||||
.where(Recordings.camera == camera_name)
|
.where(Recordings.camera == camera_name)
|
||||||
.order_by(Recordings.start_time.desc())
|
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -441,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)],
|
||||||
@@ -452,40 +532,29 @@ 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):
|
def get_clip_query(stream_type: str):
|
||||||
with sp.Popen(
|
return (
|
||||||
ffmpeg_cmd,
|
Recordings.select(
|
||||||
stderr=sp.PIPE,
|
Recordings.path,
|
||||||
stdout=sp.PIPE,
|
Recordings.start_time,
|
||||||
text=False,
|
Recordings.end_time,
|
||||||
) as ffmpeg:
|
)
|
||||||
while True:
|
.where(
|
||||||
data = ffmpeg.stdout.read(8192)
|
(Recordings.start_time.between(start_ts, end_ts))
|
||||||
if data is not None and len(data) > 0:
|
| (Recordings.end_time.between(start_ts, end_ts))
|
||||||
yield data
|
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
|
||||||
else:
|
)
|
||||||
if ffmpeg.returncode and ffmpeg.returncode != 0:
|
.where(Recordings.camera == camera_name)
|
||||||
logger.error(
|
.where(Recordings.stream_type == stream_type)
|
||||||
f"Failed to generate clip, ffmpeg logs: {ffmpeg.stderr.read()}"
|
.order_by(Recordings.start_time.asc())
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
FilePath(file_path).unlink(missing_ok=True)
|
|
||||||
break
|
|
||||||
|
|
||||||
recordings = (
|
# never mix streams in one concat; use main when available and
|
||||||
Recordings.select(
|
# fall back to sub for expired-main history
|
||||||
Recordings.path,
|
recordings = get_clip_query(STREAM_TYPE_MAIN)
|
||||||
Recordings.start_time,
|
|
||||||
Recordings.end_time,
|
if recordings.count() == 0:
|
||||||
)
|
recordings = get_clip_query(STREAM_TYPE_SUB)
|
||||||
.where(
|
|
||||||
(Recordings.start_time.between(start_ts, end_ts))
|
|
||||||
| (Recordings.end_time.between(start_ts, end_ts))
|
|
||||||
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
|
|
||||||
)
|
|
||||||
.where(Recordings.camera == camera_name)
|
|
||||||
.order_by(Recordings.start_time.asc())
|
|
||||||
)
|
|
||||||
|
|
||||||
if recordings.count() == 0:
|
if recordings.count() == 0:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -496,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
|
||||||
@@ -544,22 +615,65 @@ 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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
def _build_vod_clip(
|
||||||
"/vod/{camera_name}/start/{start_ts}/end/{end_ts}",
|
row: Any, start: float, end: float
|
||||||
dependencies=[Depends(require_camera_access)],
|
) -> tuple[dict[str, Any], int] | None:
|
||||||
description="Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
|
"""Build one nginx-vod clip dict + duration (ms) for a recording row trimmed to [start, end).
|
||||||
)
|
|
||||||
async def vod_ts(
|
Realization comes entirely from the shared plan_clip, so the coverage
|
||||||
|
endpoint's realized timelines match this manifest by construction.
|
||||||
|
"""
|
||||||
|
plan = plan_clip(row, start, end)
|
||||||
|
|
||||||
|
if plan.skipped:
|
||||||
|
return None
|
||||||
|
|
||||||
|
clip: dict[str, Any] = {"type": "source", "path": row.path}
|
||||||
|
if plan.clip_from_ms is not None:
|
||||||
|
clip["clipFrom"] = plan.clip_from_ms
|
||||||
|
if plan.key_frame_durations is not None:
|
||||||
|
# real gaps enable keyframe-aligned sub-file segments (bootstrap
|
||||||
|
# ladder); the whole-clip fallback keeps one segment per file,
|
||||||
|
# the only safe cut without an index
|
||||||
|
if plan.first_key_frame_offset_ms > 0:
|
||||||
|
clip["firstKeyFrameOffset"] = plan.first_key_frame_offset_ms
|
||||||
|
clip["keyFrameDurations"] = plan.key_frame_durations
|
||||||
|
else:
|
||||||
|
clip["keyFrameDurations"] = [plan.duration_ms]
|
||||||
|
logger.debug(
|
||||||
|
"VOD: added clip %s duration_ms=%s clipFrom=%s",
|
||||||
|
row.path,
|
||||||
|
plan.duration_ms,
|
||||||
|
clip.get("clipFrom"),
|
||||||
|
)
|
||||||
|
return clip, plan.duration_ms
|
||||||
|
|
||||||
|
|
||||||
|
async def _vod_response(
|
||||||
camera_name: str,
|
camera_name: str,
|
||||||
start_ts: float,
|
start_ts: float,
|
||||||
end_ts: float,
|
end_ts: float,
|
||||||
force_discontinuity: bool = False,
|
force_discontinuity: bool = False,
|
||||||
):
|
stream_preference: str | None = None,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Build an nginx-vod mapping JSON for a camera over a timestamp range.
|
||||||
|
|
||||||
|
Always a single-sequence mapping; quality selection happens in the
|
||||||
|
frontend by choosing between this route and the stream-pinned routes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
camera_name: The camera to build the mapping for
|
||||||
|
start_ts: Range start as a unix timestamp
|
||||||
|
end_ts: Range end as a unix timestamp
|
||||||
|
force_discontinuity: Emit HLS discontinuity markers between clips
|
||||||
|
stream_preference: Pin the manifest to one stream type ("main" or
|
||||||
|
"sub"), serving only that stream's recordings
|
||||||
|
"""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"VOD: Generating VOD for %s from %s to %s with force_discontinuity=%s",
|
"VOD: Generating VOD for %s from %s to %s with force_discontinuity=%s",
|
||||||
camera_name,
|
camera_name,
|
||||||
@@ -567,104 +681,85 @@ async def vod_ts(
|
|||||||
end_ts,
|
end_ts,
|
||||||
force_discontinuity,
|
force_discontinuity,
|
||||||
)
|
)
|
||||||
recordings = (
|
intervals = resolve_coverage(camera_name, start_ts, end_ts)
|
||||||
Recordings.select(
|
|
||||||
Recordings.path,
|
# rows contradicting their stream's audio composition are
|
||||||
Recordings.duration,
|
# truncated-shutdown glitches
|
||||||
Recordings.end_time,
|
main_audio = stream_has_audio(intervals, main=True)
|
||||||
Recordings.start_time,
|
sub_audio = stream_has_audio(intervals, main=False)
|
||||||
)
|
|
||||||
.where(
|
spans = build_spans(
|
||||||
Recordings.camera == camera_name,
|
null_audio_glitches(intervals, main_audio, sub_audio),
|
||||||
Recordings.start_time >= start_ts - MAX_SEGMENT_DURATION,
|
stream_preference,
|
||||||
Recordings.start_time <= end_ts,
|
|
||||||
Recordings.end_time >= start_ts,
|
|
||||||
)
|
|
||||||
.order_by(Recordings.start_time.asc())
|
|
||||||
.iterator()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
clips = []
|
durations: list[int] = []
|
||||||
durations = []
|
clips: list[dict[str, Any]] = []
|
||||||
min_duration_ms = 100 # Minimum 100ms to ensure at least one video frame
|
# gathered after glitch-nulling and span building, so the policy
|
||||||
max_duration_ms = MAX_SEGMENT_DURATION * 1000
|
# decisions below reflect the manifest's real contents
|
||||||
|
video_codecs: set[str] = set()
|
||||||
recording: Recordings
|
audio_presence: set[bool] = set()
|
||||||
for recording in recordings:
|
audio_params: set[tuple[str | None, int | None]] = set()
|
||||||
|
span_streams: set[bool] = set()
|
||||||
|
for row, span_start, span_end, span_is_main in spans:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"VOD: processing recording: %s start=%s end=%s duration=%s",
|
"VOD: processing recording: %s start=%s end=%s duration=%s",
|
||||||
recording.path,
|
row.path,
|
||||||
recording.start_time,
|
row.start_time,
|
||||||
recording.end_time,
|
row.end_time,
|
||||||
recording.duration,
|
row.duration,
|
||||||
)
|
)
|
||||||
|
built = _build_vod_clip(row, span_start, span_end)
|
||||||
|
|
||||||
clip = {"type": "source", "path": recording.path}
|
if built is None:
|
||||||
duration = int(recording.duration * 1000)
|
|
||||||
|
|
||||||
# adjust start offset if start_ts is after recording.start_time
|
|
||||||
if start_ts > recording.start_time:
|
|
||||||
inpoint = int((start_ts - recording.start_time) * 1000)
|
|
||||||
clip["clipFrom"] = inpoint
|
|
||||||
duration -= inpoint
|
|
||||||
logger.debug(
|
|
||||||
"VOD: applied clipFrom %sms to %s",
|
|
||||||
inpoint,
|
|
||||||
recording.path,
|
|
||||||
)
|
|
||||||
|
|
||||||
# adjust end if recording.end_time is after end_ts
|
|
||||||
if recording.end_time > end_ts:
|
|
||||||
duration -= int((recording.end_time - end_ts) * 1000)
|
|
||||||
|
|
||||||
# nginx-vod-module pushes clipFrom forward to the next keyframe,
|
|
||||||
# which can leave too few frames and produce an empty/unplayable
|
|
||||||
# segment. Snap clipFrom back to the preceding keyframe so the
|
|
||||||
# segment always starts with a decodable frame.
|
|
||||||
if "clipFrom" in clip:
|
|
||||||
keyframe_ms = get_keyframe_before(recording.path, clip["clipFrom"])
|
|
||||||
if keyframe_ms is not None:
|
|
||||||
gained = clip["clipFrom"] - keyframe_ms
|
|
||||||
clip["clipFrom"] = keyframe_ms
|
|
||||||
duration += gained
|
|
||||||
logger.debug(
|
|
||||||
"VOD: snapped clipFrom to keyframe at %sms for %s, duration now %sms",
|
|
||||||
keyframe_ms,
|
|
||||||
recording.path,
|
|
||||||
duration,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# could not read keyframes, remove clipFrom to use full recording
|
|
||||||
logger.debug(
|
|
||||||
"VOD: no keyframe info for %s, removing clipFrom to use full recording",
|
|
||||||
recording.path,
|
|
||||||
)
|
|
||||||
del clip["clipFrom"]
|
|
||||||
duration = int(recording.duration * 1000)
|
|
||||||
if recording.end_time > end_ts:
|
|
||||||
duration -= int((recording.end_time - end_ts) * 1000)
|
|
||||||
|
|
||||||
if duration < min_duration_ms:
|
|
||||||
# skip if the clip has no valid duration (too short to contain frames)
|
|
||||||
logger.debug(
|
|
||||||
"VOD: skipping recording %s - resulting duration %sms too short",
|
|
||||||
recording.path,
|
|
||||||
duration,
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if min_duration_ms <= duration < max_duration_ms:
|
clips.append(built[0])
|
||||||
clip["keyFrameDurations"] = [duration]
|
durations.append(built[1])
|
||||||
clips.append(clip)
|
span_streams.add(span_is_main)
|
||||||
durations.append(duration)
|
if row.video_codec is not None:
|
||||||
logger.debug(
|
video_codecs.add(row.video_codec)
|
||||||
"VOD: added clip %s duration_ms=%s clipFrom=%s",
|
audio_presence.add(row.has_audio is not False)
|
||||||
recording.path,
|
# legacy rows contribute no signature, so uniformly-unknown
|
||||||
duration,
|
# history keeps the legacy shape
|
||||||
clip.get("clipFrom"),
|
if row.has_audio is not False and (
|
||||||
)
|
row.audio_codec is not None or row.audio_rate is not None
|
||||||
else:
|
):
|
||||||
logger.warning(f"Recording clip is missing or empty: {recording.path}")
|
audio_params.add((row.audio_codec, row.audio_rate))
|
||||||
|
|
||||||
|
# nginx-vod requires a uniform track count per sequence, and adding or
|
||||||
|
# removing an audio track across an MSE discontinuity is unproven
|
||||||
|
if len(audio_presence) > 1:
|
||||||
|
logger.debug(
|
||||||
|
"VOD: %s mixes audio-bearing and audio-less recordings between "
|
||||||
|
"%s and %s; serving the range without audio",
|
||||||
|
camera_name,
|
||||||
|
start_ts,
|
||||||
|
end_ts,
|
||||||
|
)
|
||||||
|
for clip in clips:
|
||||||
|
clip["tracks"] = "v"
|
||||||
|
|
||||||
|
# discontinuity mode emits per-clip init segments, letting the decoder
|
||||||
|
# reconfigure at each boundary. Stream type counts as a signature of
|
||||||
|
# its own: the two encoders differ in SPS/PPS even when codec name and
|
||||||
|
# audio params match, and a single-init manifest then decode-fails on
|
||||||
|
# players that only configure from the init segment (iOS)
|
||||||
|
use_discontinuity = (
|
||||||
|
len(video_codecs) > 1 or len(audio_params) > 1 or len(span_streams) > 1
|
||||||
|
)
|
||||||
|
if use_discontinuity:
|
||||||
|
logger.debug(
|
||||||
|
"VOD: %s mixes media signatures between %s and %s (video codecs "
|
||||||
|
"%s, audio params %s, streams %s); serving a discontinuity "
|
||||||
|
"manifest with per-clip init segments",
|
||||||
|
camera_name,
|
||||||
|
start_ts,
|
||||||
|
end_ts,
|
||||||
|
sorted(video_codecs),
|
||||||
|
sorted(audio_params, key=str),
|
||||||
|
sorted(span_streams),
|
||||||
|
)
|
||||||
|
|
||||||
if not clips:
|
if not clips:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -678,16 +773,50 @@ async def vod_ts(
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if len(clips) > NGINX_VOD_MAX_CLIPS:
|
||||||
|
logger.warning(
|
||||||
|
"VOD: %s needs %d clips between %s and %s, exceeding nginx's "
|
||||||
|
"limit of %d; playback of this range will fail. This usually "
|
||||||
|
"means the camera produced abnormally short recording segments "
|
||||||
|
"(check the stream's timestamps)",
|
||||||
|
camera_name,
|
||||||
|
len(clips),
|
||||||
|
start_ts,
|
||||||
|
end_ts,
|
||||||
|
NGINX_VOD_MAX_CLIPS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# segmentation comes from the vod_* nginx directives plus per-clip
|
||||||
|
# keyFrameDurations; a segment_duration field here was always ignored
|
||||||
|
# (nginx-vod parses only camelCase segmentDuration)
|
||||||
hour_ago = datetime.now() - timedelta(hours=1)
|
hour_ago = datetime.now() - timedelta(hours=1)
|
||||||
return JSONResponse(
|
content = {
|
||||||
content={
|
"cache": hour_ago.timestamp() > start_ts,
|
||||||
"cache": hour_ago.timestamp() > start_ts,
|
"discontinuity": force_discontinuity or use_discontinuity,
|
||||||
"discontinuity": force_discontinuity,
|
"consistentSequenceMediaInfo": True,
|
||||||
"consistentSequenceMediaInfo": True,
|
"durations": durations,
|
||||||
"durations": durations,
|
"sequences": [{"clips": clips}],
|
||||||
"segment_duration": max(durations),
|
}
|
||||||
"sequences": [{"clips": clips}],
|
if use_discontinuity:
|
||||||
}
|
# clip-indexed naming is what makes nginx-vod emit per-clip
|
||||||
|
# EXT-X-MAP outside of its live mode
|
||||||
|
content["initialClipIndex"] = 1
|
||||||
|
return JSONResponse(content=content)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/vod/{camera_name}/start/{start_ts}/end/{end_ts}",
|
||||||
|
dependencies=[Depends(require_camera_access)],
|
||||||
|
description="Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
|
||||||
|
)
|
||||||
|
async def vod_ts(
|
||||||
|
camera_name: str,
|
||||||
|
start_ts: float,
|
||||||
|
end_ts: float,
|
||||||
|
force_discontinuity: bool = False,
|
||||||
|
):
|
||||||
|
return await _vod_response(
|
||||||
|
camera_name, start_ts, end_ts, force_discontinuity=force_discontinuity
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -776,7 +905,43 @@ async def vod_clip(
|
|||||||
start_ts: float,
|
start_ts: float,
|
||||||
end_ts: float,
|
end_ts: float,
|
||||||
):
|
):
|
||||||
return await vod_ts(camera_name, start_ts, end_ts, force_discontinuity=True)
|
# the tracking-details player corrects its timeline from
|
||||||
|
# sequences[0].clips[0].clipFrom
|
||||||
|
return await _vod_response(
|
||||||
|
camera_name,
|
||||||
|
start_ts,
|
||||||
|
end_ts,
|
||||||
|
force_discontinuity=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# registered after /vod/clip/... on purpose: both routes are six path
|
||||||
|
# segments, Starlette matches structurally in registration order, and the
|
||||||
|
# enum validation on {stream} would otherwise 422 every /vod/clip request
|
||||||
|
@router.get(
|
||||||
|
"/vod/{camera_name}/{stream}/start/{start_ts}/end/{end_ts}",
|
||||||
|
dependencies=[Depends(require_camera_access)],
|
||||||
|
description="Returns an HLS playlist pinned to one stream type (main or sub) for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
|
||||||
|
)
|
||||||
|
async def vod_ts_stream(
|
||||||
|
camera_name: str,
|
||||||
|
stream: VodStreamPreference,
|
||||||
|
start_ts: float,
|
||||||
|
end_ts: float,
|
||||||
|
force_discontinuity: bool = False,
|
||||||
|
):
|
||||||
|
"""VOD for a timestamp range pinned to one stream type.
|
||||||
|
|
||||||
|
How the frontend selects quality, now that mappings are always
|
||||||
|
single-sequence.
|
||||||
|
"""
|
||||||
|
return await _vod_response(
|
||||||
|
camera_name,
|
||||||
|
start_ts,
|
||||||
|
end_ts,
|
||||||
|
force_discontinuity=force_discontinuity,
|
||||||
|
stream_preference=stream.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -820,7 +985,7 @@ async def event_snapshot(
|
|||||||
# see if the object is currently being tracked
|
# see if the object is currently being tracked
|
||||||
try:
|
try:
|
||||||
camera_states: list[CameraState] = (
|
camera_states: list[CameraState] = (
|
||||||
request.app.detected_frames_processor.camera_states.values()
|
request.app.detected_frames_processor.get_camera_states()
|
||||||
)
|
)
|
||||||
for camera_state in camera_states:
|
for camera_state in camera_states:
|
||||||
if event_id in camera_state.tracked_objects:
|
if event_id in camera_state.tracked_objects:
|
||||||
@@ -898,7 +1063,7 @@ async def event_thumbnail(
|
|||||||
if thumbnail_bytes is None:
|
if thumbnail_bytes is None:
|
||||||
# see if the object is currently being tracked
|
# see if the object is currently being tracked
|
||||||
try:
|
try:
|
||||||
camera_states = request.app.detected_frames_processor.camera_states.values()
|
camera_states = request.app.detected_frames_processor.get_camera_states()
|
||||||
for camera_state in camera_states:
|
for camera_state in camera_states:
|
||||||
if event_id in camera_state.tracked_objects:
|
if event_id in camera_state.tracked_objects:
|
||||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||||
@@ -1127,7 +1292,7 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool =
|
|||||||
# see if the object is currently being tracked
|
# see if the object is currently being tracked
|
||||||
try:
|
try:
|
||||||
camera_states = (
|
camera_states = (
|
||||||
request.app.detected_frames_processor.camera_states.values()
|
request.app.detected_frames_processor.get_camera_states()
|
||||||
)
|
)
|
||||||
for camera_state in camera_states:
|
for camera_state in camera_states:
|
||||||
if event_id in camera_state.tracked_objects:
|
if event_id in camera_state.tracked_objects:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+189
-56
@@ -25,8 +25,20 @@ from frigate.api.defs.query.recordings_query_parameters import (
|
|||||||
)
|
)
|
||||||
from frigate.api.defs.response.generic_response import GenericResponse
|
from frigate.api.defs.response.generic_response import GenericResponse
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.const import MAX_SEGMENT_DURATION, RECORD_DIR
|
from frigate.const import (
|
||||||
|
MAX_SEGMENT_DURATION,
|
||||||
|
RECORD_DIR,
|
||||||
|
STREAM_TYPE_MAIN,
|
||||||
|
STREAM_TYPE_SUB,
|
||||||
|
)
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings
|
||||||
|
from frigate.util.recording_coverage import (
|
||||||
|
coverage_spans,
|
||||||
|
known_video_codecs,
|
||||||
|
realized_timelines,
|
||||||
|
resolve_coverage,
|
||||||
|
stream_media_summary,
|
||||||
|
)
|
||||||
from frigate.util.time import get_dst_transitions
|
from frigate.util.time import get_dst_transitions
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -59,7 +71,7 @@ def get_recordings_storage_usage(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def all_recordings_summary(
|
async def all_recordings_summary(
|
||||||
request: Request,
|
request: Request,
|
||||||
params: MediaRecordingsSummaryQueryParams = Depends(),
|
params: MediaRecordingsSummaryQueryParams = Depends(),
|
||||||
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
|
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
|
||||||
@@ -76,18 +88,23 @@ def all_recordings_summary(
|
|||||||
else:
|
else:
|
||||||
camera_list = allowed_cameras
|
camera_list = allowed_cameras
|
||||||
|
|
||||||
time_range_query = (
|
min_time: float | None = None
|
||||||
Recordings.select(
|
max_time: float | None = None
|
||||||
fn.MIN(Recordings.start_time).alias("min_time"),
|
for camera in camera_list:
|
||||||
fn.MAX(Recordings.start_time).alias("max_time"),
|
cam_min = (
|
||||||
|
Recordings.select(fn.MIN(Recordings.start_time))
|
||||||
|
.where(Recordings.camera == camera)
|
||||||
|
.scalar()
|
||||||
)
|
)
|
||||||
.where(Recordings.camera << camera_list)
|
if cam_min is None:
|
||||||
.dicts()
|
continue
|
||||||
.get()
|
cam_max = (
|
||||||
)
|
Recordings.select(fn.MAX(Recordings.start_time))
|
||||||
|
.where(Recordings.camera == camera)
|
||||||
min_time = time_range_query.get("min_time")
|
.scalar()
|
||||||
max_time = time_range_query.get("max_time")
|
)
|
||||||
|
min_time = cam_min if min_time is None else min(min_time, cam_min)
|
||||||
|
max_time = cam_max if max_time is None else max(max_time, cam_max)
|
||||||
|
|
||||||
if min_time is None or max_time is None:
|
if min_time is None or max_time is None:
|
||||||
return JSONResponse(content={})
|
return JSONResponse(content={})
|
||||||
@@ -97,22 +114,60 @@ def all_recordings_summary(
|
|||||||
days: dict[str, bool] = {}
|
days: dict[str, bool] = {}
|
||||||
|
|
||||||
for period_start, period_end, period_offset in dst_periods:
|
for period_start, period_end, period_offset in dst_periods:
|
||||||
day_expr = ((Recordings.start_time + period_offset) / 86400).cast("int")
|
first_start = max(min_time, period_start - MAX_SEGMENT_DURATION)
|
||||||
|
first_day = int((first_start + period_offset) // 86400)
|
||||||
|
last_day = int((min(max_time, period_end) + period_offset) // 86400)
|
||||||
|
|
||||||
period_query = (
|
day_idx = first_day
|
||||||
Recordings.select(day_expr.alias("day_idx"))
|
while day_idx <= last_day:
|
||||||
.where(
|
day_str = (dt.date(1970, 1, 1) + dt.timedelta(days=day_idx)).isoformat()
|
||||||
(Recordings.camera << camera_list)
|
day_start = day_idx * 86400 - period_offset
|
||||||
& (Recordings.end_time >= period_start)
|
day_end = day_start + 86400
|
||||||
& (Recordings.start_time <= period_end)
|
|
||||||
|
if day_str in days:
|
||||||
|
day_idx += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if day_end <= period_end:
|
||||||
|
upper = Recordings.start_time < day_end
|
||||||
|
else:
|
||||||
|
upper = Recordings.start_time <= period_end
|
||||||
|
|
||||||
|
has_recordings = (
|
||||||
|
Recordings.select(Recordings.id)
|
||||||
|
.where(
|
||||||
|
(Recordings.camera << camera_list)
|
||||||
|
& (Recordings.end_time >= period_start)
|
||||||
|
& (Recordings.start_time >= day_start)
|
||||||
|
& upper
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
)
|
)
|
||||||
.distinct()
|
if has_recordings:
|
||||||
.namedtuples()
|
days[day_str] = True
|
||||||
)
|
day_idx += 1
|
||||||
|
continue
|
||||||
|
|
||||||
for g in period_query:
|
# empty day
|
||||||
day_str = (dt.date(1970, 1, 1) + dt.timedelta(days=g.day_idx)).isoformat()
|
next_start: float | None = None
|
||||||
days[day_str] = True
|
for camera in camera_list:
|
||||||
|
cam_next = (
|
||||||
|
Recordings.select(fn.MIN(Recordings.start_time))
|
||||||
|
.where(
|
||||||
|
Recordings.camera == camera,
|
||||||
|
Recordings.start_time >= day_end,
|
||||||
|
Recordings.start_time <= period_end,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
if cam_next is not None and (
|
||||||
|
next_start is None or cam_next < next_start
|
||||||
|
):
|
||||||
|
next_start = cam_next
|
||||||
|
|
||||||
|
if next_start is None:
|
||||||
|
break
|
||||||
|
day_idx = max(day_idx + 1, int((next_start + period_offset) // 86400))
|
||||||
|
|
||||||
return JSONResponse(content=dict(sorted(days.items())))
|
return JSONResponse(content=dict(sorted(days.items())))
|
||||||
|
|
||||||
@@ -149,23 +204,28 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"):
|
|||||||
period_hour_modifier = f"{hours_offset} hour"
|
period_hour_modifier = f"{hours_offset} hour"
|
||||||
period_minute_modifier = f"{minutes_offset} minute"
|
period_minute_modifier = f"{minutes_offset} minute"
|
||||||
|
|
||||||
|
hour_expression = fn.strftime(
|
||||||
|
"%Y-%m-%d %H",
|
||||||
|
fn.datetime(
|
||||||
|
Recordings.start_time,
|
||||||
|
"unixepoch",
|
||||||
|
period_hour_modifier,
|
||||||
|
period_minute_modifier,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# sub rows duplicate the camera's motion/object stats, so
|
||||||
|
# aggregating them too would double-count
|
||||||
recording_groups = (
|
recording_groups = (
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
fn.strftime(
|
hour_expression.alias("hour"),
|
||||||
"%Y-%m-%d %H",
|
|
||||||
fn.datetime(
|
|
||||||
Recordings.start_time,
|
|
||||||
"unixepoch",
|
|
||||||
period_hour_modifier,
|
|
||||||
period_minute_modifier,
|
|
||||||
),
|
|
||||||
).alias("hour"),
|
|
||||||
fn.SUM(Recordings.duration).alias("duration"),
|
fn.SUM(Recordings.duration).alias("duration"),
|
||||||
fn.SUM(Recordings.motion).alias("motion"),
|
fn.SUM(Recordings.motion).alias("motion"),
|
||||||
fn.SUM(Recordings.objects).alias("objects"),
|
fn.SUM(Recordings.objects).alias("objects"),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
(Recordings.camera == camera_name)
|
(Recordings.camera == camera_name)
|
||||||
|
& (Recordings.stream_type == STREAM_TYPE_MAIN)
|
||||||
& (Recordings.end_time >= period_start)
|
& (Recordings.end_time >= period_start)
|
||||||
& (Recordings.start_time <= period_end)
|
& (Recordings.start_time <= period_end)
|
||||||
)
|
)
|
||||||
@@ -174,6 +234,23 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"):
|
|||||||
.namedtuples()
|
.namedtuples()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# sub recordings can outlive main, so hours covered only by sub
|
||||||
|
# rows are reported too, flagged as sub_only
|
||||||
|
sub_groups = (
|
||||||
|
Recordings.select(
|
||||||
|
hour_expression.alias("hour"),
|
||||||
|
fn.SUM(Recordings.duration).alias("duration"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
(Recordings.camera == camera_name)
|
||||||
|
& (Recordings.stream_type == STREAM_TYPE_SUB)
|
||||||
|
& (Recordings.end_time >= period_start)
|
||||||
|
& (Recordings.start_time <= period_end)
|
||||||
|
)
|
||||||
|
.group_by((Recordings.start_time + period_offset).cast("int") / 3600)
|
||||||
|
.namedtuples()
|
||||||
|
)
|
||||||
|
|
||||||
event_groups = (
|
event_groups = (
|
||||||
Event.select(
|
Event.select(
|
||||||
fn.strftime(
|
fn.strftime(
|
||||||
@@ -197,17 +274,43 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"):
|
|||||||
|
|
||||||
event_map = {g.hour: g.count for g in event_groups}
|
event_map = {g.hour: g.count for g in event_groups}
|
||||||
|
|
||||||
for recording_group in recording_groups:
|
hour_stats = [
|
||||||
parts = recording_group.hour.split()
|
(
|
||||||
|
g.hour,
|
||||||
|
{
|
||||||
|
"motion": g.motion,
|
||||||
|
"objects": g.objects,
|
||||||
|
"duration": round(g.duration),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for g in recording_groups
|
||||||
|
]
|
||||||
|
main_hours = {group_hour for group_hour, _ in hour_stats}
|
||||||
|
hour_stats.extend(
|
||||||
|
(
|
||||||
|
g.hour,
|
||||||
|
{
|
||||||
|
"motion": 0,
|
||||||
|
"objects": 0,
|
||||||
|
"duration": round(g.duration),
|
||||||
|
"sub_only": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for g in sub_groups
|
||||||
|
if g.hour not in main_hours
|
||||||
|
)
|
||||||
|
# restore the most-recent-first ordering after merging in sub hours
|
||||||
|
hour_stats.sort(key=lambda entry: entry[0], reverse=True)
|
||||||
|
|
||||||
|
for group_hour, stats in hour_stats:
|
||||||
|
parts = group_hour.split()
|
||||||
hour = parts[1]
|
hour = parts[1]
|
||||||
day = parts[0]
|
day = parts[0]
|
||||||
events_count = event_map.get(recording_group.hour, 0)
|
events_count = event_map.get(group_hour, 0)
|
||||||
hour_data = {
|
hour_data = {
|
||||||
"hour": hour,
|
"hour": hour,
|
||||||
"events": events_count,
|
"events": events_count,
|
||||||
"motion": recording_group.motion,
|
**stats,
|
||||||
"objects": recording_group.objects,
|
|
||||||
"duration": round(recording_group.duration),
|
|
||||||
}
|
}
|
||||||
if day in days:
|
if day in days:
|
||||||
# merge counts if already present (edge-case at DST boundary)
|
# merge counts if already present (edge-case at DST boundary)
|
||||||
@@ -223,6 +326,35 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"):
|
|||||||
return JSONResponse(content=list(days.values()))
|
return JSONResponse(content=list(days.values()))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{camera_name}/recordings/coverage",
|
||||||
|
dependencies=[Depends(require_camera_access)],
|
||||||
|
)
|
||||||
|
async def recordings_coverage(
|
||||||
|
camera_name: str, after: float, before: float, timelines: bool = False
|
||||||
|
):
|
||||||
|
"""Returns merged recording coverage spans plus codec compatibility.
|
||||||
|
|
||||||
|
codecs_compatible is false only when more than one known video codec
|
||||||
|
appears across the range's rows, the case where the merged vod route
|
||||||
|
degrades to a single-stream manifest.
|
||||||
|
"""
|
||||||
|
intervals = resolve_coverage(camera_name, after, before)
|
||||||
|
|
||||||
|
content = {
|
||||||
|
"spans": coverage_spans(intervals),
|
||||||
|
"codecs_compatible": len(known_video_codecs(intervals)) <= 1,
|
||||||
|
"streams": stream_media_summary(intervals),
|
||||||
|
}
|
||||||
|
|
||||||
|
# pure computation (shared plan_clip, record-time keyframe index), but
|
||||||
|
# opt-in for payload hygiene: day-level requests need only the spans
|
||||||
|
if timelines:
|
||||||
|
content["timelines"] = realized_timelines(intervals)
|
||||||
|
|
||||||
|
return JSONResponse(content=content)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
|
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
|
||||||
async def recordings(
|
async def recordings(
|
||||||
camera_name: str,
|
camera_name: str,
|
||||||
@@ -243,6 +375,7 @@ async def recordings(
|
|||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Recordings.camera == camera_name,
|
Recordings.camera == camera_name,
|
||||||
|
Recordings.stream_type == STREAM_TYPE_MAIN,
|
||||||
Recordings.start_time >= after - MAX_SEGMENT_DURATION,
|
Recordings.start_time >= after - MAX_SEGMENT_DURATION,
|
||||||
Recordings.end_time >= after,
|
Recordings.end_time >= after,
|
||||||
Recordings.start_time <= before,
|
Recordings.start_time <= before,
|
||||||
@@ -283,22 +416,22 @@ async def no_recordings(
|
|||||||
)
|
)
|
||||||
scale = params.scale
|
scale = params.scale
|
||||||
|
|
||||||
clauses = [
|
recordings: list[tuple[float, float]] = []
|
||||||
(Recordings.end_time >= after) & (Recordings.start_time <= before),
|
for camera in camera_list:
|
||||||
(Recordings.camera << camera_list),
|
recordings.extend(
|
||||||
]
|
Recordings.select(Recordings.start_time, Recordings.end_time)
|
||||||
|
.where(
|
||||||
|
Recordings.camera == camera,
|
||||||
|
Recordings.start_time >= after - MAX_SEGMENT_DURATION,
|
||||||
|
Recordings.end_time >= after,
|
||||||
|
Recordings.start_time <= before,
|
||||||
|
)
|
||||||
|
.tuples()
|
||||||
|
.iterator()
|
||||||
|
)
|
||||||
|
|
||||||
# Get recording start times
|
# the merge pass below expects a single start-ordered timeline
|
||||||
data: list[Recordings] = (
|
recordings.sort()
|
||||||
Recordings.select(Recordings.start_time, Recordings.end_time)
|
|
||||||
.where(reduce(operator.and_, clauses))
|
|
||||||
.order_by(Recordings.start_time.asc())
|
|
||||||
.dicts()
|
|
||||||
.iterator()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert recordings to list of (start, end) tuples, ordered by start_time
|
|
||||||
recordings = [(r["start_time"], r["end_time"]) for r in data]
|
|
||||||
|
|
||||||
# Merge overlapping/adjacent recordings into covered intervals. The query
|
# Merge overlapping/adjacent recordings into covered intervals. The query
|
||||||
# orders by start_time, so a single pass merges them
|
# orders by start_time, so a single pass merges them
|
||||||
|
|||||||
+11
-1
@@ -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 (
|
||||||
@@ -32,6 +33,7 @@ from frigate.api.defs.response.review_response import (
|
|||||||
ReviewSummaryResponse,
|
ReviewSummaryResponse,
|
||||||
)
|
)
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
|
from frigate.const import STREAM_TYPE_MAIN
|
||||||
from frigate.embeddings import EmbeddingsContext
|
from frigate.embeddings import EmbeddingsContext
|
||||||
from frigate.models import Recordings, ReviewSegment, UserReviewStatus
|
from frigate.models import Recordings, ReviewSegment, UserReviewStatus
|
||||||
from frigate.review.types import SeverityEnum
|
from frigate.review.types import SeverityEnum
|
||||||
@@ -597,6 +599,8 @@ def motion_activity(
|
|||||||
|
|
||||||
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
|
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
|
||||||
clauses.append(Recordings.motion > 0)
|
clauses.append(Recordings.motion > 0)
|
||||||
|
# sub rows duplicate the camera's motion stats, so only count main rows
|
||||||
|
clauses.append(Recordings.stream_type == STREAM_TYPE_MAIN)
|
||||||
|
|
||||||
if cameras != "all":
|
if cameras != "all":
|
||||||
requested = set(cameras.split(","))
|
requested = set(cameras.split(","))
|
||||||
@@ -709,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),
|
||||||
):
|
):
|
||||||
@@ -727,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,
|
||||||
@@ -743,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."""
|
||||||
|
|||||||
+145
-78
@@ -6,12 +6,18 @@ 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
|
||||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||||
from frigate.comms.webpush import WebPushClient
|
from frigate.comms.webpush import WebPushClient
|
||||||
from frigate.config import BirdseyeModeConfig, FrigateConfig
|
from frigate.config import (
|
||||||
|
FrigateConfig,
|
||||||
|
birdseye_modes_from_mqtt_payload,
|
||||||
|
birdseye_modes_to_mqtt_payload,
|
||||||
|
)
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdatePublisher,
|
CameraConfigUpdatePublisher,
|
||||||
@@ -45,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."""
|
||||||
@@ -84,7 +95,7 @@ class Dispatcher:
|
|||||||
"recordings": self._on_recordings_command,
|
"recordings": self._on_recordings_command,
|
||||||
"snapshots": self._on_snapshots_command,
|
"snapshots": self._on_snapshots_command,
|
||||||
"birdseye": self._on_birdseye_command,
|
"birdseye": self._on_birdseye_command,
|
||||||
"birdseye_mode": self._on_birdseye_mode_command,
|
"birdseye_modes": self._on_birdseye_modes_command,
|
||||||
"review_alerts": self._on_alerts_command,
|
"review_alerts": self._on_alerts_command,
|
||||||
"review_detections": self._on_detections_command,
|
"review_detections": self._on_detections_command,
|
||||||
"object_descriptions": self._on_object_description_command,
|
"object_descriptions": self._on_object_description_command,
|
||||||
@@ -99,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)
|
||||||
|
|
||||||
@@ -123,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,
|
||||||
@@ -149,7 +256,21 @@ class Dispatcher:
|
|||||||
restart_frigate()
|
restart_frigate()
|
||||||
|
|
||||||
def handle_insert_many_recordings() -> None:
|
def handle_insert_many_recordings() -> None:
|
||||||
Recordings.insert_many(payload).execute()
|
try:
|
||||||
|
Recordings.insert_many(payload).execute()
|
||||||
|
except IntegrityError:
|
||||||
|
logger.warning(
|
||||||
|
"Batch recording insert failed, inserting rows individually"
|
||||||
|
)
|
||||||
|
|
||||||
|
for recording in payload:
|
||||||
|
try:
|
||||||
|
Recordings.insert(recording).execute()
|
||||||
|
except IntegrityError:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping recording that is already stored: %s",
|
||||||
|
recording.get(Recordings.path.name),
|
||||||
|
)
|
||||||
|
|
||||||
def handle_request_region_grid() -> Any:
|
def handle_request_region_grid() -> Any:
|
||||||
camera = payload
|
camera = payload
|
||||||
@@ -268,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,
|
||||||
@@ -351,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"):
|
||||||
@@ -880,12 +945,12 @@ class Dispatcher:
|
|||||||
)
|
)
|
||||||
self.publish(f"{camera_name}/birdseye/state", payload, retain=True)
|
self.publish(f"{camera_name}/birdseye/state", payload, retain=True)
|
||||||
|
|
||||||
def _on_birdseye_mode_command(self, camera_name: str, payload: str) -> None:
|
def _on_birdseye_modes_command(self, camera_name: str, payload: str) -> None:
|
||||||
"""Callback for birdseye mode topic."""
|
"""Callback for birdseye mode topic."""
|
||||||
|
|
||||||
mode = BirdseyeModeConfig.from_mqtt_payload(payload)
|
modes = birdseye_modes_from_mqtt_payload(payload)
|
||||||
if mode is None:
|
if modes is None:
|
||||||
logger.info("Invalid birdseye_mode command: %s", payload)
|
logger.info("Invalid birdseye_modes command: %s", payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
birdseye_settings = self.config.cameras[camera_name].birdseye
|
birdseye_settings = self.config.cameras[camera_name].birdseye
|
||||||
@@ -894,9 +959,9 @@ class Dispatcher:
|
|||||||
logger.info(f"Birdseye mode not enabled for {camera_name}")
|
logger.info(f"Birdseye mode not enabled for {camera_name}")
|
||||||
return
|
return
|
||||||
|
|
||||||
birdseye_settings.mode = mode
|
birdseye_settings.modes = modes
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Setting birdseye mode for {camera_name} to {birdseye_settings.mode}"
|
f"Setting birdseye mode for {camera_name} to {birdseye_settings.modes}"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.config_updater.publish_update(
|
self.config_updater.publish_update(
|
||||||
@@ -904,7 +969,9 @@ class Dispatcher:
|
|||||||
birdseye_settings,
|
birdseye_settings,
|
||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
f"{camera_name}/birdseye_mode/state", mode.to_mqtt_payload(), retain=True
|
f"{camera_name}/birdseye_modes/state",
|
||||||
|
birdseye_modes_to_mqtt_payload(modes),
|
||||||
|
retain=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_camera_notification_command(self, camera_name: str, payload: str) -> None:
|
def _on_camera_notification_command(self, camera_name: str, payload: str) -> None:
|
||||||
|
|||||||
@@ -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,7 +66,8 @@ class InterProcessCommunicator(Communicator):
|
|||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
self.reader_thread.join()
|
if self.reader_thread is not None:
|
||||||
|
self.reader_thread.join()
|
||||||
self.socket.close(linger=0)
|
self.socket.close(linger=0)
|
||||||
self.context.destroy(linger=0)
|
self.context.destroy(linger=0)
|
||||||
|
|
||||||
|
|||||||
+671
-170
@@ -1,16 +1,38 @@
|
|||||||
|
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
|
||||||
|
|
||||||
from frigate.comms.base_communicator import Communicator
|
from frigate.comms.base_communicator import Communicator
|
||||||
from frigate.config import FrigateConfig
|
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:
|
||||||
self.client.disconnect()
|
return
|
||||||
|
|
||||||
|
if self.connected and self._subscription_ready:
|
||||||
|
publish_done = threading.Event()
|
||||||
|
self._publish_queue.put(
|
||||||
|
QueuedPublish(
|
||||||
|
f"{self.mqtt_config.topic_prefix}/available",
|
||||||
|
"stopped",
|
||||||
|
True,
|
||||||
|
publish_done,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
publish_done.wait(MQTT_SHUTDOWN_FLUSH_TIMEOUT)
|
||||||
|
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
if self.client is not None:
|
||||||
|
try:
|
||||||
|
self.client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("MQTT disconnect raised during shutdown", exc_info=True)
|
||||||
|
|
||||||
|
if self._worker.is_alive():
|
||||||
|
self._worker.join(MQTT_SHUTDOWN_FLUSH_TIMEOUT + MQTT_LOOP_TIMEOUT)
|
||||||
|
|
||||||
|
self._cleanup_client()
|
||||||
|
self._worker = None
|
||||||
|
|
||||||
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(
|
||||||
@@ -123,9 +197,9 @@ class MqttClient(Communicator):
|
|||||||
retain=True,
|
retain=True,
|
||||||
)
|
)
|
||||||
self.publish(
|
self.publish(
|
||||||
f"{camera_name}/birdseye_mode/state",
|
f"{camera_name}/birdseye_modes/state",
|
||||||
(
|
(
|
||||||
camera.birdseye.mode.to_mqtt_payload()
|
birdseye_modes_to_mqtt_payload(camera.birdseye.modes)
|
||||||
if camera.birdseye.enabled
|
if camera.birdseye.enabled
|
||||||
else "OFF"
|
else "OFF"
|
||||||
),
|
),
|
||||||
@@ -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":
|
else:
|
||||||
logger.error(
|
self._callback_queue.put(("connect_failure", reason_code))
|
||||||
"Unable to connect to MQTT server: MQTT Server unavailable"
|
|
||||||
)
|
|
||||||
elif reason_code == "Bad user name or password":
|
|
||||||
logger.error(
|
|
||||||
"Unable to connect to MQTT server: MQTT Bad username or password"
|
|
||||||
)
|
|
||||||
elif reason_code == "Not authorized":
|
|
||||||
logger.error("Unable to connect to MQTT server: MQTT Not authorized")
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
"Unable to connect to MQTT server: Connection refused. Error code: %s",
|
|
||||||
reason_code.getName(),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.connected = True
|
|
||||||
logger.debug("MQTT connected")
|
|
||||||
client.subscribe(f"{self.mqtt_config.topic_prefix}/#", qos=self.config.mqtt.qos)
|
|
||||||
self._set_initial_topics()
|
|
||||||
|
|
||||||
def _on_disconnect(
|
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_mode",
|
|
||||||
"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:
|
||||||
)
|
return
|
||||||
|
|
||||||
# notifications suspend doesn't follow the /set topic pattern
|
self._callback_queue.put(("published", mid))
|
||||||
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:
|
def _on_message(
|
||||||
self.client.message_callback_add(
|
self,
|
||||||
f"{self.mqtt_config.topic_prefix}/{name}/ptz",
|
client: mqtt.Client,
|
||||||
self.on_mqtt_command,
|
userdata: Any,
|
||||||
)
|
message: mqtt.MQTTMessage,
|
||||||
|
) -> None:
|
||||||
|
"""Queue inbound MQTT messages for processing in the worker loop."""
|
||||||
|
topic = self._strip_topic_prefix(message.topic)
|
||||||
|
|
||||||
for mask_name in self.config.cameras[name].motion.mask.keys():
|
# Ignore everything outside Frigate's command surface before decoding or
|
||||||
self.client.message_callback_add(
|
# dispatching into the rest of the app.
|
||||||
f"{self.mqtt_config.topic_prefix}/{name}/motion_mask/{mask_name}/set",
|
if not self._is_supported_command_topic(topic):
|
||||||
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
|
||||||
|
|
||||||
|
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/"
|
||||||
|
|
||||||
|
|||||||
+31
-17
@@ -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,10 +219,14 @@ 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"]
|
||||||
if not self.config.cameras[camera].notifications.enabled:
|
camera_config = self.config.cameras.get(camera)
|
||||||
|
|
||||||
|
if camera_config is None or not camera_config.notifications.enabled:
|
||||||
return
|
return
|
||||||
if self.is_camera_suspended(camera):
|
if self.is_camera_suspended(camera):
|
||||||
logger.debug(f"Notifications for {camera} are currently suspended.")
|
logger.debug(f"Notifications for {camera} are currently suspended.")
|
||||||
@@ -230,13 +240,14 @@ class WebPushClient(Communicator):
|
|||||||
|
|
||||||
# ensure notifications are enabled and the specific trigger has
|
# ensure notifications are enabled and the specific trigger has
|
||||||
# notification action enabled
|
# notification action enabled
|
||||||
|
camera_config = self.config.cameras.get(camera)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not self.config.cameras[camera].notifications.enabled
|
camera_config is None
|
||||||
or name not in self.config.cameras[camera].semantic_search.triggers
|
or not camera_config.notifications.enabled
|
||||||
|
or name not in camera_config.semantic_search.triggers
|
||||||
or "notification"
|
or "notification"
|
||||||
not in self.config.cameras[camera]
|
not in camera_config.semantic_search.triggers[name].actions
|
||||||
.semantic_search.triggers[name]
|
|
||||||
.actions
|
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -247,7 +258,9 @@ class WebPushClient(Communicator):
|
|||||||
elif topic == "camera_monitoring":
|
elif topic == "camera_monitoring":
|
||||||
decoded = json.loads(payload)
|
decoded = json.loads(payload)
|
||||||
camera = decoded["camera"]
|
camera = decoded["camera"]
|
||||||
if not self.config.cameras[camera].notifications.enabled:
|
camera_config = self.config.cameras.get(camera)
|
||||||
|
|
||||||
|
if camera_config is None or not camera_config.notifications.enabled:
|
||||||
return
|
return
|
||||||
if self.is_camera_suspended(camera):
|
if self.is_camera_suspended(camera):
|
||||||
logger.debug(f"Notifications for {camera} are currently suspended.")
|
logger.debug(f"Notifications for {camera} are currently suspended.")
|
||||||
@@ -598,4 +611,5 @@ class WebPushClient(Communicator):
|
|||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
logger.info("Closing notification queue")
|
logger.info("Closing notification queue")
|
||||||
self.notification_thread.join()
|
if self.notification_thread is not None:
|
||||||
|
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",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..base import FrigateBaseModel
|
from ..base import FrigateBaseModel
|
||||||
@@ -6,78 +8,58 @@ __all__ = [
|
|||||||
"BirdseyeCameraConfig",
|
"BirdseyeCameraConfig",
|
||||||
"BirdseyeConfig",
|
"BirdseyeConfig",
|
||||||
"BirdseyeLayoutConfig",
|
"BirdseyeLayoutConfig",
|
||||||
"BirdseyeModeConfig",
|
"BirdseyeModeEnum",
|
||||||
|
"birdseye_modes_from_mqtt_payload",
|
||||||
|
"birdseye_modes_to_mqtt_payload",
|
||||||
]
|
]
|
||||||
|
|
||||||
BIRDSEYE_ACTIVITY_TYPES = (
|
# canonical MQTT payload for an empty mode list
|
||||||
"objects",
|
MQTT_NO_MODES = "NONE"
|
||||||
"motion",
|
|
||||||
"stationary_objects",
|
|
||||||
"continuous",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BirdseyeModeConfig(FrigateBaseModel):
|
class BirdseyeModeEnum(str, Enum):
|
||||||
continuous: bool = Field(
|
continuous = "continuous"
|
||||||
default=False,
|
motion = "motion"
|
||||||
title="Continuous",
|
all_objects = "all_objects"
|
||||||
description="Always include the camera in Birdseye.",
|
alerts = "alerts"
|
||||||
)
|
detections = "detections"
|
||||||
motion: bool = Field(
|
|
||||||
default=False,
|
|
||||||
title="Motion",
|
|
||||||
description="Include the camera in Birdseye when motion is detected.",
|
|
||||||
)
|
|
||||||
objects: bool = Field(
|
|
||||||
default=False,
|
|
||||||
title="Active objects",
|
|
||||||
description="Include the camera in Birdseye while an active object is tracked.",
|
|
||||||
)
|
|
||||||
stationary_objects: bool = Field(
|
|
||||||
default=False,
|
|
||||||
title="Stationary objects",
|
|
||||||
description="Include the camera in Birdseye while a stationary object is tracked.",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mqtt_payload(cls, payload: str) -> "BirdseyeModeConfig | None":
|
def birdseye_modes_from_mqtt_payload(payload: str) -> list[BirdseyeModeEnum] | None:
|
||||||
"""Create mode options from an uppercase MQTT payload."""
|
"""Parse an uppercase MQTT payload into activity modes, or None when invalid."""
|
||||||
raw_modes = payload.split(",")
|
raw_modes = payload.split(",")
|
||||||
if not raw_modes or any(not mode for mode in raw_modes):
|
|
||||||
|
if any(not raw_mode or raw_mode != raw_mode.upper() for raw_mode in raw_modes):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if raw_modes == [MQTT_NO_MODES]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
modes: list[BirdseyeModeEnum] = []
|
||||||
|
|
||||||
|
for raw_mode in raw_modes:
|
||||||
|
try:
|
||||||
|
mode = BirdseyeModeEnum(raw_mode.lower())
|
||||||
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
modes = [mode.lower() for mode in raw_modes]
|
if mode in modes:
|
||||||
if any(
|
|
||||||
raw_mode != mode.upper() or mode not in BIRDSEYE_ACTIVITY_TYPES
|
|
||||||
for raw_mode, mode in zip(raw_modes, modes)
|
|
||||||
):
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if len(modes) != len(set(modes)):
|
modes.append(mode)
|
||||||
return None
|
|
||||||
|
|
||||||
return cls(**{mode: True for mode in modes})
|
return modes
|
||||||
|
|
||||||
def has_enabled_activity(self) -> bool:
|
|
||||||
"""Return whether at least one activity type is enabled."""
|
|
||||||
return any(getattr(self, activity) for activity in BIRDSEYE_ACTIVITY_TYPES)
|
|
||||||
|
|
||||||
def to_mqtt_payload(self) -> str:
|
|
||||||
"""Serialize enabled mode options for MQTT state topics."""
|
|
||||||
payload = ",".join(
|
|
||||||
activity.upper()
|
|
||||||
for activity in BIRDSEYE_ACTIVITY_TYPES
|
|
||||||
if getattr(self, activity)
|
|
||||||
)
|
|
||||||
if not payload:
|
|
||||||
raise ValueError("At least one Birdseye activity type must be enabled")
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def default_birdseye_mode() -> BirdseyeModeConfig:
|
def birdseye_modes_to_mqtt_payload(modes: list[BirdseyeModeEnum]) -> str:
|
||||||
"""Return the default Birdseye mode configuration."""
|
"""Serialize activity modes for MQTT state topics."""
|
||||||
return BirdseyeModeConfig(objects=True)
|
payload = ",".join(mode.value.upper() for mode in BirdseyeModeEnum if mode in modes)
|
||||||
|
return payload or MQTT_NO_MODES
|
||||||
|
|
||||||
|
|
||||||
|
def default_birdseye_modes() -> list[BirdseyeModeEnum]:
|
||||||
|
"""Return the default Birdseye activity modes."""
|
||||||
|
return [BirdseyeModeEnum.all_objects]
|
||||||
|
|
||||||
|
|
||||||
class BirdseyeLayoutConfig(FrigateBaseModel):
|
class BirdseyeLayoutConfig(FrigateBaseModel):
|
||||||
@@ -101,8 +83,8 @@ class BirdseyeConfig(FrigateBaseModel):
|
|||||||
title="Enable Birdseye",
|
title="Enable Birdseye",
|
||||||
description="Enable or disable the Birdseye view feature.",
|
description="Enable or disable the Birdseye view feature.",
|
||||||
)
|
)
|
||||||
mode: BirdseyeModeConfig = Field(
|
modes: list[BirdseyeModeEnum] = Field(
|
||||||
default_factory=default_birdseye_mode,
|
default_factory=default_birdseye_modes,
|
||||||
title="Activity types",
|
title="Activity types",
|
||||||
description="Activity types that include cameras in Birdseye.",
|
description="Activity types that include cameras in Birdseye.",
|
||||||
)
|
)
|
||||||
@@ -156,8 +138,8 @@ class BirdseyeCameraConfig(BaseModel):
|
|||||||
title="Enable Birdseye",
|
title="Enable Birdseye",
|
||||||
description="Enable or disable the Birdseye view feature.",
|
description="Enable or disable the Birdseye view feature.",
|
||||||
)
|
)
|
||||||
mode: BirdseyeModeConfig = Field(
|
modes: list[BirdseyeModeEnum] = Field(
|
||||||
default_factory=default_birdseye_mode,
|
default_factory=default_birdseye_modes,
|
||||||
title="Activity types",
|
title="Activity types",
|
||||||
description="Activity types that include cameras in Birdseye.",
|
description="Activity types that include cameras in Birdseye.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ from enum import Enum
|
|||||||
|
|
||||||
from pydantic import Field, PrivateAttr, model_validator
|
from pydantic import Field, PrivateAttr, model_validator
|
||||||
|
|
||||||
from frigate.const import CACHE_DIR, CACHE_SEGMENT_FORMAT, REGEX_CAMERA_NAME
|
from frigate.const import (
|
||||||
|
CACHE_DIR,
|
||||||
|
CACHE_SEGMENT_FORMAT,
|
||||||
|
REGEX_CAMERA_NAME,
|
||||||
|
SUB_CACHE_TAG,
|
||||||
|
)
|
||||||
from frigate.ffmpeg_presets import (
|
from frigate.ffmpeg_presets import (
|
||||||
parse_preset_hardware_acceleration_decode,
|
parse_preset_hardware_acceleration_decode,
|
||||||
parse_preset_hardware_acceleration_scale,
|
parse_preset_hardware_acceleration_scale,
|
||||||
@@ -215,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
|
||||||
@@ -294,6 +304,28 @@ class CameraConfig(FrigateBaseModel):
|
|||||||
+ ffmpeg_output_args
|
+ ffmpeg_output_args
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"record_sub" in ffmpeg_input.roles
|
||||||
|
and self.record.enabled
|
||||||
|
and self.record.sub.enabled
|
||||||
|
):
|
||||||
|
sub_output_args = self.ffmpeg.output_args.effective_record_sub
|
||||||
|
record_args = get_ffmpeg_arg_list(
|
||||||
|
parse_preset_output_record(
|
||||||
|
sub_output_args,
|
||||||
|
self.ffmpeg.apple_compatibility,
|
||||||
|
)
|
||||||
|
or sub_output_args
|
||||||
|
)
|
||||||
|
|
||||||
|
ffmpeg_output_args = (
|
||||||
|
record_args
|
||||||
|
+ [
|
||||||
|
f"{os.path.join(CACHE_DIR, self.name)}{SUB_CACHE_TAG}@{CACHE_SEGMENT_FORMAT}.mp4"
|
||||||
|
]
|
||||||
|
+ ffmpeg_output_args
|
||||||
|
)
|
||||||
|
|
||||||
# if there aren't any outputs enabled for this input
|
# if there aren't any outputs enabled for this input
|
||||||
if len(ffmpeg_output_args) == 0:
|
if len(ffmpeg_output_args) == 0:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ class FfmpegOutputArgsConfig(FrigateBaseModel):
|
|||||||
title="Record output arguments",
|
title="Record output arguments",
|
||||||
description="Default output arguments for record role streams.",
|
description="Default output arguments for record role streams.",
|
||||||
)
|
)
|
||||||
|
record_sub: str | list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
title="Sub stream record output arguments",
|
||||||
|
description="Output arguments for record_sub role streams. The record output arguments are used when this is not set.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_record_sub(self) -> str | list[str]:
|
||||||
|
"""Output arguments used for the record_sub role.
|
||||||
|
|
||||||
|
Falls back to the record arguments rather than to the stock preset so
|
||||||
|
that a customized record value keeps applying to both recorded streams.
|
||||||
|
"""
|
||||||
|
return self.record_sub or self.record
|
||||||
|
|
||||||
|
|
||||||
class FfmpegConfig(FrigateBaseModel):
|
class FfmpegConfig(FrigateBaseModel):
|
||||||
@@ -99,6 +113,7 @@ class FfmpegConfig(FrigateBaseModel):
|
|||||||
class CameraRoleEnum(str, Enum):
|
class CameraRoleEnum(str, Enum):
|
||||||
audio = "audio"
|
audio = "audio"
|
||||||
record = "record"
|
record = "record"
|
||||||
|
record_sub = "record_sub"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -13,6 +13,7 @@ __all__ = [
|
|||||||
"RecordExportConfig",
|
"RecordExportConfig",
|
||||||
"RecordPreviewConfig",
|
"RecordPreviewConfig",
|
||||||
"RecordQualityEnum",
|
"RecordQualityEnum",
|
||||||
|
"RecordSubConfig",
|
||||||
"EventsConfig",
|
"EventsConfig",
|
||||||
"ReviewRetainConfig",
|
"ReviewRetainConfig",
|
||||||
"RecordRetainConfig",
|
"RecordRetainConfig",
|
||||||
@@ -110,6 +111,34 @@ class RecordExportConfig(FrigateBaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordSubConfig(FrigateBaseModel):
|
||||||
|
enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
title="Enable sub stream recording",
|
||||||
|
description="Enable recording of a second, lower quality stream for adaptive quality playback and extended retention.",
|
||||||
|
)
|
||||||
|
continuous: RecordRetainConfig = Field(
|
||||||
|
default_factory=RecordRetainConfig,
|
||||||
|
title="Sub stream continuous retention",
|
||||||
|
description="Number of days to retain sub stream recordings regardless of tracked objects or motion.",
|
||||||
|
)
|
||||||
|
motion: RecordRetainConfig = Field(
|
||||||
|
default_factory=RecordRetainConfig,
|
||||||
|
title="Sub stream motion retention",
|
||||||
|
description="Number of days to retain sub stream recordings triggered by motion.",
|
||||||
|
)
|
||||||
|
alerts: ReviewRetainConfig = Field(
|
||||||
|
default_factory=ReviewRetainConfig,
|
||||||
|
title="Sub stream alert retention",
|
||||||
|
description="Retention settings for sub stream recordings of alerts.",
|
||||||
|
)
|
||||||
|
detections: ReviewRetainConfig = Field(
|
||||||
|
default_factory=ReviewRetainConfig,
|
||||||
|
title="Sub stream detection retention",
|
||||||
|
description="Retention settings for sub stream recordings of detections.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RecordConfig(FrigateBaseModel):
|
class RecordConfig(FrigateBaseModel):
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -151,12 +180,42 @@ class RecordConfig(FrigateBaseModel):
|
|||||||
title="Preview config",
|
title="Preview config",
|
||||||
description="Settings controlling the quality of recording previews shown in the UI.",
|
description="Settings controlling the quality of recording previews shown in the UI.",
|
||||||
)
|
)
|
||||||
|
sub: RecordSubConfig = Field(
|
||||||
|
default_factory=RecordSubConfig,
|
||||||
|
title="Sub stream recording",
|
||||||
|
description="Settings for recording a second, lower quality stream.",
|
||||||
|
)
|
||||||
enabled_in_config: bool | None = Field(
|
enabled_in_config: bool | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
title="Original recording state",
|
title="Original recording state",
|
||||||
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
|
||||||
|
def effective_alert_days(self) -> float:
|
||||||
|
"""Alert retention extended to the sub stream window when sub is enabled.
|
||||||
|
|
||||||
|
Review items and tracked objects must stay visible for as long as
|
||||||
|
either stream still has recordings.
|
||||||
|
"""
|
||||||
|
if self.sub.enabled:
|
||||||
|
return max(self.alerts.retain.days, self.sub.alerts.days)
|
||||||
|
return self.alerts.retain.days
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_detection_days(self) -> float:
|
||||||
|
"""Detection retention extended to the sub window when sub is enabled."""
|
||||||
|
if self.sub.enabled:
|
||||||
|
return max(self.detections.retain.days, self.sub.detections.days)
|
||||||
|
return self.detections.retain.days
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def event_pre_capture(self) -> int:
|
def event_pre_capture(self) -> int:
|
||||||
return max(
|
return max(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -129,8 +130,13 @@ class CameraConfigUpdateSubscriber:
|
|||||||
config.objects = updated_config
|
config.objects = updated_config
|
||||||
elif update_type == CameraConfigUpdateEnum.record:
|
elif update_type == CameraConfigUpdateEnum.record:
|
||||||
old_enabled_in_config = config.record.enabled_in_config
|
old_enabled_in_config = config.record.enabled_in_config
|
||||||
|
old_sub_enabled = config.record.sub.enabled
|
||||||
config.record = updated_config
|
config.record = updated_config
|
||||||
if old_enabled_in_config != updated_config.enabled_in_config:
|
# the record and record_sub ffmpeg outputs are gated on these
|
||||||
|
if (
|
||||||
|
old_enabled_in_config != updated_config.enabled_in_config
|
||||||
|
or old_sub_enabled != updated_config.sub.enabled
|
||||||
|
):
|
||||||
config.recreate_ffmpeg_cmds()
|
config.recreate_ffmpeg_cmds()
|
||||||
elif update_type == CameraConfigUpdateEnum.review:
|
elif update_type == CameraConfigUpdateEnum.review:
|
||||||
config.review = updated_config
|
config.review = updated_config
|
||||||
|
|||||||
+82
-42
@@ -41,7 +41,7 @@ from .auth import AuthConfig
|
|||||||
from .base import FrigateBaseModel
|
from .base import FrigateBaseModel
|
||||||
from .camera import CameraConfig, CameraLiveConfig
|
from .camera import CameraConfig, CameraLiveConfig
|
||||||
from .camera.audio import AudioConfig, AudioFilterConfig
|
from .camera.audio import AudioConfig, AudioFilterConfig
|
||||||
from .camera.birdseye import BirdseyeConfig, BirdseyeModeConfig
|
from .camera.birdseye import BirdseyeConfig
|
||||||
from .camera.detect import DetectConfig
|
from .camera.detect import DetectConfig
|
||||||
from .camera.ffmpeg import FfmpegConfig
|
from .camera.ffmpeg import FfmpegConfig
|
||||||
from .camera.genai import GenAIConfig, GenAIRoleEnum
|
from .camera.genai import GenAIConfig, GenAIRoleEnum
|
||||||
@@ -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
|
||||||
@@ -260,6 +260,21 @@ def verify_config_roles(camera_config: CameraConfig) -> None:
|
|||||||
f"Camera {camera_config.name} has record enabled, but record is not assigned to an input."
|
f"Camera {camera_config.name} has record enabled, but record is not assigned to an input."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
camera_config.record.enabled
|
||||||
|
and camera_config.record.sub.enabled
|
||||||
|
and "record_sub" not in assigned_roles
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Camera {camera_config.name} has sub stream recording enabled, but record_sub is not assigned to an input."
|
||||||
|
)
|
||||||
|
|
||||||
|
for ffmpeg_input in camera_config.ffmpeg.inputs:
|
||||||
|
if "record" in ffmpeg_input.roles and "record_sub" in ffmpeg_input.roles:
|
||||||
|
raise ValueError(
|
||||||
|
f"Camera {camera_config.name} has record and record_sub assigned to the same input, which would record the same stream twice."
|
||||||
|
)
|
||||||
|
|
||||||
if camera_config.audio.enabled and "audio" not in assigned_roles:
|
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."
|
||||||
@@ -280,13 +295,11 @@ def verify_valid_live_stream_names(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_recording_segments_setup_with_reasonable_time(
|
def verify_record_output_args_segment_time(
|
||||||
camera_config: CameraConfig,
|
camera_config: CameraConfig, output_args: str | list[str], role: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Verify that recording segments are setup and segment time is not greater than 60."""
|
"""Verify that a recording role's output args segment at a reasonable time."""
|
||||||
record_args: list[str] = get_ffmpeg_arg_list(
|
record_args: list[str] = get_ffmpeg_arg_list(output_args)
|
||||||
camera_config.ffmpeg.output_args.record
|
|
||||||
)
|
|
||||||
|
|
||||||
if record_args[0].startswith("preset"):
|
if record_args[0].startswith("preset"):
|
||||||
return
|
return
|
||||||
@@ -296,16 +309,32 @@ def verify_recording_segments_setup_with_reasonable_time(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Camera {camera_config.name} has no segment_time in \
|
f"Camera {camera_config.name} has no segment_time in \
|
||||||
recording output args, segment args are required for record."
|
{role} output args, segment args are required for record."
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
if int(record_args[seg_arg_index + 1]) > 60:
|
if int(record_args[seg_arg_index + 1]) > 60:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Camera {camera_config.name} has invalid segment_time output arg, \
|
f"Camera {camera_config.name} has invalid segment_time in {role} output args, \
|
||||||
segment_time must be 60 or less."
|
segment_time must be 60 or less."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_recording_segments_setup_with_reasonable_time(
|
||||||
|
camera_config: CameraConfig,
|
||||||
|
) -> None:
|
||||||
|
"""Verify that recording segments are setup and segment time is not greater than 60."""
|
||||||
|
verify_record_output_args_segment_time(
|
||||||
|
camera_config, camera_config.ffmpeg.output_args.record, "recording"
|
||||||
|
)
|
||||||
|
|
||||||
|
if camera_config.record.sub.enabled:
|
||||||
|
verify_record_output_args_segment_time(
|
||||||
|
camera_config,
|
||||||
|
camera_config.ffmpeg.output_args.effective_record_sub,
|
||||||
|
"sub stream recording",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_zone_objects_are_tracked(camera_config: CameraConfig) -> None:
|
def verify_zone_objects_are_tracked(camera_config: CameraConfig) -> None:
|
||||||
"""Verify that user has not entered zone objects that are not in the tracking config."""
|
"""Verify that user has not entered zone objects that are not in the tracking config."""
|
||||||
for zone_name, zone in camera_config.zones.items():
|
for zone_name, zone in camera_config.zones.items():
|
||||||
@@ -331,20 +360,8 @@ def verify_required_zones_exist(camera_config: CameraConfig) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def verify_profile_overrides_match_base(camera_config: CameraConfig) -> None:
|
def verify_profile_overrides_match_base(camera_config: CameraConfig) -> None:
|
||||||
"""Verify profile overrides against the resolved base camera configuration."""
|
"""Verify that profile zone and mask IDs reference entries defined on the base camera."""
|
||||||
for profile_name, profile in camera_config.profiles.items():
|
for profile_name, profile in camera_config.profiles.items():
|
||||||
if profile.birdseye is not None:
|
|
||||||
overrides = profile.birdseye.mode.model_dump(exclude_unset=True)
|
|
||||||
base_mode = camera_config.birdseye.mode.model_dump()
|
|
||||||
resolved_mode = BirdseyeModeConfig.model_validate(
|
|
||||||
deep_merge(overrides, base_mode)
|
|
||||||
)
|
|
||||||
if not resolved_mode.has_enabled_activity():
|
|
||||||
raise ValueError(
|
|
||||||
f"Camera '{camera_config.name}' profile '{profile_name}' must "
|
|
||||||
"enable at least one Birdseye activity type"
|
|
||||||
)
|
|
||||||
|
|
||||||
if profile.zones:
|
if profile.zones:
|
||||||
for zone_name in profile.zones:
|
for zone_name in profile.zones:
|
||||||
if zone_name not in camera_config.zones:
|
if zone_name not in camera_config.zones:
|
||||||
@@ -676,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.
|
||||||
@@ -796,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}
|
||||||
|
model = by_scene.get(scene)
|
||||||
|
|
||||||
if scene is not None:
|
if model is not None:
|
||||||
model = by_scene.get(scene)
|
|
||||||
|
|
||||||
if model is 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")
|
||||||
@@ -1169,10 +1210,6 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
self.cameras[name] = camera_config
|
self.cameras[name] = camera_config
|
||||||
|
|
||||||
verify_config_roles(camera_config)
|
verify_config_roles(camera_config)
|
||||||
if not camera_config.birdseye.mode.has_enabled_activity():
|
|
||||||
raise ValueError(
|
|
||||||
f"Camera '{name}' must enable at least one Birdseye activity type"
|
|
||||||
)
|
|
||||||
verify_valid_live_stream_names(self, camera_config)
|
verify_valid_live_stream_names(self, camera_config)
|
||||||
verify_recording_segments_setup_with_reasonable_time(camera_config)
|
verify_recording_segments_setup_with_reasonable_time(camera_config)
|
||||||
verify_zone_objects_are_tracked(camera_config)
|
verify_zone_objects_are_tracked(camera_config)
|
||||||
@@ -1276,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
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from datetime import UTC, datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from frigate.config.camera.birdseye import birdseye_modes_to_mqtt_payload
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdatePublisher,
|
CameraConfigUpdatePublisher,
|
||||||
@@ -42,9 +43,11 @@ SECTION_STATE_TOPICS: dict[str, list[tuple[str, Callable[[Any], Any]]]] = {
|
|||||||
"birdseye": [
|
"birdseye": [
|
||||||
("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"),
|
("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"),
|
||||||
(
|
(
|
||||||
"birdseye_mode",
|
"birdseye_modes",
|
||||||
lambda c: (
|
lambda c: (
|
||||||
c.birdseye.mode.to_mqtt_payload() if c.birdseye.enabled else "OFF"
|
birdseye_modes_to_mqtt_payload(c.birdseye.modes)
|
||||||
|
if c.birdseye.enabled
|
||||||
|
else "OFF"
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ SHM_FRAMES_VAR = "SHM_MAX_FRAMES"
|
|||||||
|
|
||||||
REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"
|
REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"
|
||||||
|
|
||||||
|
# Stream type constants
|
||||||
|
|
||||||
|
STREAM_TYPE_MAIN = "main"
|
||||||
|
STREAM_TYPE_SUB = "sub"
|
||||||
|
SUB_CACHE_TAG = "@sub"
|
||||||
|
RECORD_STREAM_TYPES = (STREAM_TYPE_MAIN, STREAM_TYPE_SUB)
|
||||||
|
ROLE_TO_STREAM_TYPE = {"record": STREAM_TYPE_MAIN, "record_sub": STREAM_TYPE_SUB}
|
||||||
|
STREAM_TYPE_TO_ROLE = {v: k for k, v in ROLE_TO_STREAM_TYPE.items()}
|
||||||
|
|
||||||
# Attribute & Object constants
|
# Attribute & Object constants
|
||||||
|
|
||||||
DEFAULT_ATTRIBUTE_LABEL_MAP = {
|
DEFAULT_ATTRIBUTE_LABEL_MAP = {
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
|||||||
"""
|
"""
|
||||||
event_id = data["event_id"]
|
event_id = data["event_id"]
|
||||||
camera_name = data["camera"]
|
camera_name = data["camera"]
|
||||||
|
camera_config = self.config.cameras.get(camera_name)
|
||||||
|
|
||||||
|
if camera_config is None:
|
||||||
|
return
|
||||||
|
|
||||||
if data_type == PostProcessDataEnum.recording:
|
if data_type == PostProcessDataEnum.recording:
|
||||||
start_ts = data["frame_time"]
|
start_ts = data["frame_time"]
|
||||||
@@ -104,7 +108,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
audio_data = get_audio_from_recording(
|
audio_data = get_audio_from_recording(
|
||||||
self.config.cameras[camera_name].ffmpeg,
|
camera_config.ffmpeg,
|
||||||
camera_name,
|
camera_name,
|
||||||
start_ts,
|
start_ts,
|
||||||
end_ts,
|
end_ts,
|
||||||
|
|||||||
@@ -151,7 +151,12 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
|||||||
logger.error(f"Event {event_id} not found for description regeneration")
|
logger.error(f"Event {event_id} not found for description regeneration")
|
||||||
return
|
return
|
||||||
|
|
||||||
camera_config = self.config.cameras[str(event.camera)]
|
camera_config = self.config.cameras.get(str(event.camera))
|
||||||
|
|
||||||
|
if camera_config is None:
|
||||||
|
logger.error("Camera %s no longer exists", event.camera)
|
||||||
|
return
|
||||||
|
|
||||||
if not camera_config.objects.genai.enabled and not force:
|
if not camera_config.objects.genai.enabled and not force:
|
||||||
logger.error(f"GenAI not enabled for camera {event.camera}")
|
logger.error(f"GenAI not enabled for camera {event.camera}")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -137,7 +138,10 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
return
|
return
|
||||||
|
|
||||||
camera = data["after"]["camera"]
|
camera = data["after"]["camera"]
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras.get(camera)
|
||||||
|
|
||||||
|
if camera_config is None:
|
||||||
|
return
|
||||||
|
|
||||||
if not camera_config.review.genai.enabled:
|
if not camera_config.review.genai.enabled:
|
||||||
return
|
return
|
||||||
@@ -438,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()
|
||||||
@@ -609,6 +609,18 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
# Embed the thumbnail
|
# Embed the thumbnail
|
||||||
self._embed_thumbnail(event_id, thumbnail)
|
self._embed_thumbnail(event_id, thumbnail)
|
||||||
|
|
||||||
|
# every post processor below reads config.cameras[camera], but
|
||||||
|
# tracked_events still has to be released or the thumbnails held
|
||||||
|
# for this event leak, same as the two exits above
|
||||||
|
if camera not in self.config.cameras:
|
||||||
|
logger.debug("Skipping post processing for removed camera %s", camera)
|
||||||
|
|
||||||
|
for processor in self.post_processors:
|
||||||
|
if isinstance(processor, ObjectDescriptionProcessor):
|
||||||
|
processor.cleanup_event(event_id)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
# call any defined post processors
|
# call any defined post processors
|
||||||
for processor in self.post_processors:
|
for processor in self.post_processors:
|
||||||
if isinstance(processor, LicensePlatePostProcessor):
|
if isinstance(processor, LicensePlatePostProcessor):
|
||||||
@@ -666,11 +678,18 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
to_remove = []
|
to_remove = []
|
||||||
|
|
||||||
for id, data in self.detected_license_plates.items():
|
for id, data in self.detected_license_plates.items():
|
||||||
|
camera_config = self.config.cameras.get(data["camera"])
|
||||||
|
|
||||||
|
if camera_config is None:
|
||||||
|
# camera was removed, drop the entry rather than expiring it
|
||||||
|
to_remove.append(id)
|
||||||
|
continue
|
||||||
|
|
||||||
last_seen = data.get("last_seen", 0)
|
last_seen = data.get("last_seen", 0)
|
||||||
if not last_seen:
|
if not last_seen:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if now - last_seen > self.config.cameras[data["camera"]].lpr.expire_time:
|
if now - last_seen > camera_config.lpr.expire_time:
|
||||||
to_remove.append(id)
|
to_remove.append(id)
|
||||||
for id in to_remove:
|
for id in to_remove:
|
||||||
self.event_metadata_publisher.publish(
|
self.event_metadata_publisher.publish(
|
||||||
@@ -695,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
|
||||||
|
|||||||
@@ -197,9 +197,11 @@ class EventCleanup(threading.Thread):
|
|||||||
|
|
||||||
def expire_clips(self) -> list[str]:
|
def expire_clips(self) -> list[str]:
|
||||||
## Expire events from unlisted cameras based on the global config
|
## Expire events from unlisted cameras based on the global config
|
||||||
|
# effective days cover the sub window, keeping tracked objects in
|
||||||
|
# Explore while sub recordings and review items still exist
|
||||||
expire_days = max(
|
expire_days = max(
|
||||||
self.config.record.alerts.retain.days,
|
self.config.record.effective_alert_days,
|
||||||
self.config.record.detections.retain.days,
|
self.config.record.effective_detection_days,
|
||||||
)
|
)
|
||||||
file_extension = None # mp4 clips are no longer stored in /clips
|
file_extension = None # mp4 clips are no longer stored in /clips
|
||||||
update_params = {"has_clip": False}
|
update_params = {"has_clip": False}
|
||||||
@@ -278,15 +280,13 @@ class EventCleanup(threading.Thread):
|
|||||||
|
|
||||||
## Expire events from cameras based on the camera config
|
## Expire events from cameras based on the camera config
|
||||||
for name, camera in self.config.cameras.items():
|
for name, camera in self.config.cameras.items():
|
||||||
expire_days = max(
|
# effective days cover the sub window, keeping tracked objects
|
||||||
camera.record.alerts.retain.days,
|
# in Explore while sub recordings and review items still exist
|
||||||
camera.record.detections.retain.days,
|
|
||||||
)
|
|
||||||
alert_expire_date = (
|
alert_expire_date = (
|
||||||
now - datetime.timedelta(days=camera.record.alerts.retain.days)
|
now - datetime.timedelta(days=camera.record.effective_alert_days)
|
||||||
).timestamp()
|
).timestamp()
|
||||||
detection_expire_date = (
|
detection_expire_date = (
|
||||||
now - datetime.timedelta(days=camera.record.detections.retain.days)
|
now - datetime.timedelta(days=camera.record.effective_detection_days)
|
||||||
).timestamp()
|
).timestamp()
|
||||||
# grab all events after specific time
|
# grab all events after specific time
|
||||||
expired_events = (
|
expired_events = (
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -453,7 +453,7 @@ def get_tool_definitions(
|
|||||||
"motion",
|
"motion",
|
||||||
"enabled",
|
"enabled",
|
||||||
"birdseye",
|
"birdseye",
|
||||||
"birdseye_mode",
|
"birdseye_modes",
|
||||||
"improve_contrast",
|
"improve_contrast",
|
||||||
"ptz_autotracker",
|
"ptz_autotracker",
|
||||||
"motion_contour_area",
|
"motion_contour_area",
|
||||||
@@ -468,7 +468,7 @@ def get_tool_definitions(
|
|||||||
],
|
],
|
||||||
"description": (
|
"description": (
|
||||||
"The feature to change. Most features accept ON or OFF. "
|
"The feature to change. Most features accept ON or OFF. "
|
||||||
"birdseye_mode accepts CONTINUOUS, MOTION, OBJECTS, STATIONARY_OBJECTS, or a comma-separated combination. "
|
"birdseye_modes accepts CONTINUOUS, MOTION, ALL_OBJECTS, ALERTS, DETECTIONS, NONE, or a comma-separated combination. "
|
||||||
"motion_contour_area and motion_threshold accept a number. "
|
"motion_contour_area and motion_threshold accept a number. "
|
||||||
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
|
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import numpy as np
|
|||||||
|
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import UPDATE_JOB_STATE
|
from frigate.const import STREAM_TYPE_MAIN, UPDATE_JOB_STATE
|
||||||
from frigate.jobs.job import Job
|
from frigate.jobs.job import Job
|
||||||
from frigate.jobs.manager import (
|
from frigate.jobs.manager import (
|
||||||
get_job_by_id,
|
get_job_by_id,
|
||||||
@@ -485,6 +485,7 @@ class MotionSearchRunner(threading.Thread):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.where(Recordings.camera == camera_name)
|
.where(Recordings.camera == camera_name)
|
||||||
|
.where(Recordings.stream_type == STREAM_TYPE_MAIN)
|
||||||
.order_by(Recordings.start_time.asc())
|
.order_by(Recordings.start_time.asc())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ class Recordings(Model):
|
|||||||
segment_size = FloatField(default=0) # this should be stored as MB
|
segment_size = FloatField(default=0) # this should be stored as MB
|
||||||
regions = IntegerField(null=True)
|
regions = IntegerField(null=True)
|
||||||
motion_heatmap = JSONField(null=True) # 16x16 grid, 256 values (0-255)
|
motion_heatmap = JSONField(null=True) # 16x16 grid, 256 values (0-255)
|
||||||
|
keyframes = JSONField(null=True) # ms offsets; NULL = unprobed (legacy rows)
|
||||||
|
stream_type = CharField(default="main", max_length=8)
|
||||||
|
has_audio = BooleanField(null=True) # NULL = unknown (legacy rows)
|
||||||
|
audio_rate = IntegerField(null=True) # Hz; NULL = unknown (legacy rows)
|
||||||
|
audio_codec = CharField(null=True, max_length=20) # NULL = unknown (legacy rows)
|
||||||
|
video_codec = CharField(null=True, max_length=20) # NULL = unknown (legacy rows)
|
||||||
|
|
||||||
|
|
||||||
class ExportCase(Model):
|
class ExportCase(Model):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+80
-40
@@ -17,9 +17,11 @@ import cv2
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import BirdseyeModeConfig, FfmpegConfig, FrigateConfig
|
from frigate.comms.review_updater import ReviewDataSubscriber
|
||||||
|
from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig
|
||||||
from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT
|
from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT
|
||||||
from frigate.output.ws_auth import ws_has_camera_access
|
from frigate.output.ws_auth import ws_has_camera_access
|
||||||
|
from frigate.review.types import SeverityEnum
|
||||||
from frigate.util.image import (
|
from frigate.util.image import (
|
||||||
SharedMemoryFrameManager,
|
SharedMemoryFrameManager,
|
||||||
copy_yuv_to_position,
|
copy_yuv_to_position,
|
||||||
@@ -33,9 +35,9 @@ logger = logging.getLogger(__name__)
|
|||||||
class BirdseyeActivity:
|
class BirdseyeActivity:
|
||||||
"""Activity signals used to decide whether a camera is shown in Birdseye."""
|
"""Activity signals used to decide whether a camera is shown in Birdseye."""
|
||||||
|
|
||||||
has_active_object: bool
|
has_object: bool
|
||||||
has_stationary_object: bool
|
|
||||||
has_motion: bool
|
has_motion: bool
|
||||||
|
severity: str | None
|
||||||
|
|
||||||
|
|
||||||
def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
|
def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
|
||||||
@@ -367,6 +369,7 @@ class BirdsEyeFrameManager:
|
|||||||
settings.detect.height,
|
settings.detect.height,
|
||||||
],
|
],
|
||||||
"last_active_frame": 0.0,
|
"last_active_frame": 0.0,
|
||||||
|
"live_active": False,
|
||||||
"current_frame": 0.0,
|
"current_frame": 0.0,
|
||||||
"layout_frame": 0.0,
|
"layout_frame": 0.0,
|
||||||
"channel_dims": {
|
"channel_dims": {
|
||||||
@@ -418,16 +421,32 @@ class BirdsEyeFrameManager:
|
|||||||
channel_dims,
|
channel_dims,
|
||||||
)
|
)
|
||||||
|
|
||||||
def camera_active(
|
def camera_threshold_active(
|
||||||
self,
|
self,
|
||||||
mode: BirdseyeModeConfig,
|
modes: list[BirdseyeModeEnum],
|
||||||
activity: BirdseyeActivity,
|
activity: BirdseyeActivity,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
"""Return whether activity subject to inactivity_threshold is present."""
|
||||||
|
return (BirdseyeModeEnum.motion in modes and activity.has_motion) or (
|
||||||
|
BirdseyeModeEnum.all_objects in modes and activity.has_object
|
||||||
|
)
|
||||||
|
|
||||||
|
def camera_live_active(
|
||||||
|
self,
|
||||||
|
modes: list[BirdseyeModeEnum],
|
||||||
|
activity: BirdseyeActivity,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether activity that ends the moment it stops is present."""
|
||||||
return (
|
return (
|
||||||
mode.continuous
|
BirdseyeModeEnum.continuous in modes
|
||||||
or (mode.motion and activity.has_motion)
|
or (
|
||||||
or (mode.objects and activity.has_active_object)
|
BirdseyeModeEnum.alerts in modes
|
||||||
or (mode.stationary_objects and activity.has_stationary_object)
|
and activity.severity == SeverityEnum.alert
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
BirdseyeModeEnum.detections in modes
|
||||||
|
and activity.severity == SeverityEnum.detection
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_camera_coordinates(self) -> dict[str, dict[str, int]]:
|
def get_camera_coordinates(self) -> dict[str, dict[str, int]]:
|
||||||
@@ -459,9 +478,15 @@ class BirdsEyeFrameManager:
|
|||||||
and self.config.cameras[cam].birdseye.enabled
|
and self.config.cameras[cam].birdseye.enabled
|
||||||
and self.config.cameras[cam].enabled_in_config
|
and self.config.cameras[cam].enabled_in_config
|
||||||
and self.config.cameras[cam].enabled
|
and self.config.cameras[cam].enabled
|
||||||
and cam_data["last_active_frame"] > 0
|
and (
|
||||||
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
|
cam_data["live_active"]
|
||||||
< self.config.birdseye.inactivity_threshold
|
or (
|
||||||
|
cam_data["last_active_frame"] > 0
|
||||||
|
and cam_data["current_frame_time"]
|
||||||
|
- cam_data["last_active_frame"]
|
||||||
|
< self.config.birdseye.inactivity_threshold
|
||||||
|
)
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
logger.debug(f"Active cameras: {active_cameras}")
|
logger.debug(f"Active cameras: {active_cameras}")
|
||||||
@@ -478,7 +503,9 @@ class BirdsEyeFrameManager:
|
|||||||
limited_active_cameras = sorted(
|
limited_active_cameras = sorted(
|
||||||
active_cameras,
|
active_cameras,
|
||||||
key=lambda active_camera: (
|
key=lambda active_camera: (
|
||||||
self.cameras[active_camera]["current_frame_time"]
|
0.0
|
||||||
|
if self.cameras[active_camera]["live_active"]
|
||||||
|
else self.cameras[active_camera]["current_frame_time"]
|
||||||
- self.cameras[active_camera]["last_active_frame"]
|
- self.cameras[active_camera]["last_active_frame"]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -747,25 +774,30 @@ class BirdsEyeFrameManager:
|
|||||||
return False, False
|
return False, False
|
||||||
|
|
||||||
force_update = False
|
force_update = False
|
||||||
|
camera_state = self.cameras.get(camera)
|
||||||
|
|
||||||
|
if camera_state is None:
|
||||||
|
return False, False
|
||||||
|
|
||||||
# disabling birdseye is a little tricky
|
# disabling birdseye is a little tricky
|
||||||
if not camera_config.birdseye.enabled or not camera_config.enabled:
|
if not camera_config.birdseye.enabled or not camera_config.enabled:
|
||||||
# if we've rendered a frame (we have a value for last_active_frame)
|
# if we've rendered a frame (we have activity state) then clear it
|
||||||
# then we need to set it to zero
|
if camera_state["last_active_frame"] > 0 or camera_state["live_active"]:
|
||||||
if self.cameras[camera]["last_active_frame"] > 0:
|
camera_state["last_active_frame"] = 0
|
||||||
self.cameras[camera]["last_active_frame"] = 0
|
camera_state["live_active"] = False
|
||||||
force_update = True
|
force_update = True
|
||||||
else:
|
else:
|
||||||
return False, False
|
return False, False
|
||||||
|
|
||||||
# update the last active frame for the camera
|
# update the last active frame for the camera
|
||||||
self.cameras[camera]["current_frame"] = frame.copy()
|
camera_state["current_frame"] = frame.copy()
|
||||||
self.cameras[camera]["current_frame_time"] = frame_time
|
camera_state["current_frame_time"] = frame_time
|
||||||
if self.camera_active(
|
modes = camera_config.birdseye.modes
|
||||||
camera_config.birdseye.mode,
|
|
||||||
activity,
|
if self.camera_threshold_active(modes, activity):
|
||||||
):
|
camera_state["last_active_frame"] = frame_time
|
||||||
self.cameras[camera]["last_active_frame"] = frame_time
|
|
||||||
|
camera_state["live_active"] = self.camera_live_active(modes, activity)
|
||||||
|
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
@@ -824,6 +856,8 @@ class Birdseye:
|
|||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.requestor = InterProcessRequestor()
|
self.requestor = InterProcessRequestor()
|
||||||
|
self.review_subscriber = ReviewDataSubscriber("")
|
||||||
|
self.review_severity: dict[str, str] = {}
|
||||||
self.idle_fps: float = self.config.birdseye.idle_heartbeat_fps
|
self.idle_fps: float = self.config.birdseye.idle_heartbeat_fps
|
||||||
self._idle_interval: float | None = (
|
self._idle_interval: float | None = (
|
||||||
(1.0 / self.idle_fps) if self.idle_fps > 0 else None
|
(1.0 / self.idle_fps) if self.idle_fps > 0 else None
|
||||||
@@ -854,6 +888,21 @@ class Birdseye:
|
|||||||
self.birdseye_manager.clear_frame()
|
self.birdseye_manager.clear_frame()
|
||||||
self.__send_new_frame()
|
self.__send_new_frame()
|
||||||
|
|
||||||
|
def check_review_updates(self) -> None:
|
||||||
|
"""Drain review updates so each camera's active severity stays current."""
|
||||||
|
while True:
|
||||||
|
update = self.review_subscriber.check_for_update(timeout=0)
|
||||||
|
|
||||||
|
if update is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
camera = update["after"]["camera"]
|
||||||
|
|
||||||
|
if update["type"] == "end":
|
||||||
|
self.review_severity.pop(camera, None)
|
||||||
|
else:
|
||||||
|
self.review_severity[camera] = update["after"]["severity"]
|
||||||
|
|
||||||
def add_camera(self, camera: str) -> None:
|
def add_camera(self, camera: str) -> None:
|
||||||
"""Add a camera to the birdseye manager."""
|
"""Add a camera to the birdseye manager."""
|
||||||
self.birdseye_manager.add_camera(camera)
|
self.birdseye_manager.add_camera(camera)
|
||||||
@@ -862,6 +911,7 @@ class Birdseye:
|
|||||||
def remove_camera(self, camera: str) -> None:
|
def remove_camera(self, camera: str) -> None:
|
||||||
"""Remove a camera from the birdseye manager."""
|
"""Remove a camera from the birdseye manager."""
|
||||||
self.birdseye_manager.remove_camera(camera)
|
self.birdseye_manager.remove_camera(camera)
|
||||||
|
self.review_severity.pop(camera, None)
|
||||||
logger.debug(f"Removed camera {camera} from birdseye")
|
logger.debug(f"Removed camera {camera} from birdseye")
|
||||||
|
|
||||||
def write_data(
|
def write_data(
|
||||||
@@ -872,24 +922,13 @@ class Birdseye:
|
|||||||
frame_time: float,
|
frame_time: float,
|
||||||
frame: np.ndarray,
|
frame: np.ndarray,
|
||||||
) -> None:
|
) -> None:
|
||||||
has_active_object = False
|
|
||||||
has_stationary_object = False
|
|
||||||
for tracked_object in current_tracked_objects:
|
|
||||||
if tracked_object["stationary"]:
|
|
||||||
if not tracked_object["false_positive"]:
|
|
||||||
has_stationary_object = True
|
|
||||||
else:
|
|
||||||
# Preserve the existing objects activity behavior, which includes
|
|
||||||
# non-stationary trackers before they are confirmed.
|
|
||||||
has_active_object = True
|
|
||||||
|
|
||||||
if has_active_object and has_stationary_object:
|
|
||||||
break
|
|
||||||
|
|
||||||
activity = BirdseyeActivity(
|
activity = BirdseyeActivity(
|
||||||
has_active_object=has_active_object,
|
has_object=any(
|
||||||
has_stationary_object=has_stationary_object,
|
not tracked_object["false_positive"]
|
||||||
|
for tracked_object in current_tracked_objects
|
||||||
|
),
|
||||||
has_motion=bool(motion_boxes),
|
has_motion=bool(motion_boxes),
|
||||||
|
severity=self.review_severity.get(camera),
|
||||||
)
|
)
|
||||||
|
|
||||||
frame_changed, frame_layout_changed = self.birdseye_manager.update(
|
frame_changed, frame_layout_changed = self.birdseye_manager.update(
|
||||||
@@ -915,5 +954,6 @@ class Birdseye:
|
|||||||
self.__send_new_frame()
|
self.__send_new_frame()
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
|
self.review_subscriber.stop()
|
||||||
self.converter.join()
|
self.converter.join()
|
||||||
self.broadcaster.join()
|
self.broadcaster.join()
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ def check_disabled_camera_update(
|
|||||||
|
|
||||||
for camera, last_update in write_times.items():
|
for camera, last_update in write_times.items():
|
||||||
offline_time = now - last_update
|
offline_time = now - last_update
|
||||||
|
camera_config = config.cameras.get(camera)
|
||||||
|
|
||||||
if config.cameras[camera].enabled:
|
if camera_config is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if camera_config.enabled:
|
||||||
has_enabled_camera = True
|
has_enabled_camera = True
|
||||||
else:
|
else:
|
||||||
# flag camera as offline when it is disabled
|
# flag camera as offline when it is disabled
|
||||||
@@ -62,8 +66,8 @@ def check_disabled_camera_update(
|
|||||||
# last camera update was more than 1 second ago
|
# last camera update was more than 1 second ago
|
||||||
# need to send empty data to birdseye because current
|
# need to send empty data to birdseye because current
|
||||||
# frame is now out of date
|
# frame is now out of date
|
||||||
cam_width = config.cameras[camera].detect.width
|
cam_width = camera_config.detect.width
|
||||||
cam_height = config.cameras[camera].detect.height
|
cam_height = camera_config.detect.height
|
||||||
|
|
||||||
if cam_width is None or cam_height is None:
|
if cam_width is None or cam_height is None:
|
||||||
raise ValueError(f"Camera {camera} detect dimensions not configured")
|
raise ValueError(f"Camera {camera} detect dimensions not configured")
|
||||||
@@ -184,6 +188,11 @@ class OutputProcess(FrigateProcess):
|
|||||||
self.config.birdseye = birdseye_config
|
self.config.birdseye = birdseye_config
|
||||||
logger.debug("Applied dynamic birdseye config update")
|
logger.debug("Applied dynamic birdseye config update")
|
||||||
|
|
||||||
|
# drain review updates every iteration, not just when birdseye is
|
||||||
|
# being consumed, so a dropped end never strands a camera
|
||||||
|
if birdseye is not None:
|
||||||
|
birdseye.check_review_updates()
|
||||||
|
|
||||||
# check if there is an updated config
|
# check if there is an updated config
|
||||||
updates = config_subscriber.check_for_updates()
|
updates = config_subscriber.check_for_updates()
|
||||||
|
|
||||||
@@ -309,10 +318,11 @@ class OutputProcess(FrigateProcess):
|
|||||||
regions,
|
regions,
|
||||||
) = data
|
) = data
|
||||||
|
|
||||||
frame = frame_manager.get(
|
camera_config = self.config.cameras.get(camera)
|
||||||
frame_name, self.config.cameras[camera].frame_shape_yuv
|
|
||||||
)
|
if camera_config is not None:
|
||||||
frame_manager.close(frame_name)
|
frame_manager.get(frame_name, camera_config.frame_shape_yuv)
|
||||||
|
frame_manager.close(frame_name)
|
||||||
|
|
||||||
detection_subscriber.stop()
|
detection_subscriber.stop()
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user