mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-07 22:23:00 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a3abd472a | ||
|
|
9fe12ed0b7 |
@@ -124,7 +124,5 @@ jobs:
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate"
|
||||
- name: Check API spec is up to date
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
|
||||
- name: Check analytics schema is up to date
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_analytics_schema.py --check"
|
||||
- name: Run unit tests in devcontainer
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m frigate.test"
|
||||
|
||||
@@ -383,7 +383,6 @@ FROM deps AS frigate
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=standard
|
||||
|
||||
# Pre-compile bytecode so a read-only rootfs doesn't force re-parsing the
|
||||
# source tree on every boot (pip-installed packages are already compiled)
|
||||
|
||||
@@ -14,17 +14,11 @@ fi
|
||||
# Tell S6-Overlay not to restart this service
|
||||
s6-svc -O .
|
||||
|
||||
# The helper runs as root (-I skips user site-packages). The probe runs as the
|
||||
# service user when the service drops privileges; a root probe relies on
|
||||
# resolve_ffmpeg_path refusing runtime-user-writable paths.
|
||||
# Runs as root with root's HOME and LD_LIBRARY_PATH; -I skips user site-packages
|
||||
function set_libva_version() {
|
||||
local ffmpeg_path
|
||||
local probe_as=()
|
||||
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||
probe_as=(s6-setuidgid frigate)
|
||||
fi
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py frigate)
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("${probe_as[@]}" "$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||
export LIBAVFORMAT_VERSION_MAJOR
|
||||
}
|
||||
|
||||
|
||||
@@ -57,19 +57,10 @@ function get_ip_and_port_from_supervisor() {
|
||||
export FRIGATE_GO2RTC_WEBRTC_CANDIDATE_INTERNAL="${ip_address}:${webrtc_port}"
|
||||
}
|
||||
|
||||
# The probe runs as the service user when the service drops privileges; a root
|
||||
# probe relies on resolve_ffmpeg_path refusing runtime-user-writable paths
|
||||
function set_libva_version() {
|
||||
local ffmpeg_path
|
||||
local probe_as=()
|
||||
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||
probe_as=(s6-setuidgid go2rtc)
|
||||
fi
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py go2rtc)
|
||||
if ! LIBAVFORMAT_VERSION_MAJOR=$("${probe_as[@]}" "$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+"); then
|
||||
echo "[WARN] Could not read the libavformat version from ${ffmpeg_path} as the go2rtc user; using the bundled build's version"
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("${probe_as[@]}" "/usr/lib/ffmpeg/${DEFAULT_FFMPEG_VERSION}/bin/ffmpeg" -version | grep -Po "libavformat\W+\K\d+")
|
||||
fi
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||
export LIBAVFORMAT_VERSION_MAJOR
|
||||
}
|
||||
|
||||
|
||||
@@ -25,5 +25,4 @@ except FileNotFoundError:
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
path = config.get("ffmpeg", {}).get("path", "default")
|
||||
service = sys.argv[1] if len(sys.argv) > 1 else "frigate"
|
||||
print(resolve_ffmpeg_path(path, "ffmpeg", service))
|
||||
print(resolve_ffmpeg_path(path, "ffmpeg"))
|
||||
|
||||
@@ -8,17 +8,15 @@ from typing import Any
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
sys.path.insert(0, "/opt/frigate")
|
||||
from frigate.const import BIRDSEYE_PIPE
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||
from frigate.util.config import (
|
||||
find_config_file,
|
||||
is_valid_ffmpeg_path,
|
||||
resolve_ffmpeg_path,
|
||||
from frigate.const import (
|
||||
BIRDSEYE_PIPE,
|
||||
LIBAVFORMAT_VERSION_MAJOR,
|
||||
)
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||
from frigate.util.config import find_config_file, resolve_ffmpeg_path
|
||||
from frigate.util.env import apply_config_env_vars, substitute_frigate_vars
|
||||
from frigate.util.live_streams import raw_transcode_streams
|
||||
from frigate.util.services import (
|
||||
apply_go2rtc_ffmpeg_config,
|
||||
is_go2rtc_arbitrary_exec_allowed,
|
||||
is_restricted_go2rtc_source,
|
||||
)
|
||||
@@ -99,17 +97,20 @@ if go2rtc_config.get("rtsp", {}).get("password") is not None:
|
||||
|
||||
# ensure ffmpeg path is set correctly
|
||||
path = config.get("ffmpeg", {}).get("path", "default")
|
||||
if not is_valid_ffmpeg_path(path):
|
||||
print(
|
||||
"[ERROR] ffmpeg path contains spaces, quotes or shell characters, using the default ffmpeg instead."
|
||||
)
|
||||
ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg", "go2rtc")
|
||||
ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg")
|
||||
|
||||
for key in apply_go2rtc_ffmpeg_config(go2rtc_config, ffmpeg_path):
|
||||
print(
|
||||
f"[ERROR] go2rtc option '{key}' was ignored because it is disabled by default for security. "
|
||||
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec."
|
||||
)
|
||||
if go2rtc_config.get("ffmpeg") is None:
|
||||
go2rtc_config["ffmpeg"] = {"bin": ffmpeg_path}
|
||||
elif go2rtc_config["ffmpeg"].get("bin") is None:
|
||||
go2rtc_config["ffmpeg"]["bin"] = ffmpeg_path
|
||||
|
||||
# need to replace ffmpeg command when using ffmpeg4
|
||||
if LIBAVFORMAT_VERSION_MAJOR < 59:
|
||||
rtsp_args = "-fflags nobuffer -flags low_delay -stimeout 10000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i {input}"
|
||||
if go2rtc_config.get("ffmpeg") is None:
|
||||
go2rtc_config["ffmpeg"] = {"rtsp": rtsp_args}
|
||||
elif go2rtc_config["ffmpeg"].get("rtsp") is None:
|
||||
go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args
|
||||
|
||||
|
||||
for name in list(go2rtc_config.get("streams", {})):
|
||||
@@ -120,7 +121,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
formatted_stream = substitute_frigate_vars(stream)
|
||||
if is_restricted_go2rtc_source(formatted_stream):
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec or ${{VAR}}) 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. "
|
||||
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
|
||||
)
|
||||
del go2rtc_config["streams"][name]
|
||||
@@ -139,7 +140,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
formatted_stream = substitute_frigate_vars(stream_item)
|
||||
if is_restricted_go2rtc_source(formatted_stream):
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec or ${{VAR}}) 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. "
|
||||
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
|
||||
)
|
||||
continue
|
||||
@@ -155,7 +156,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
go2rtc_config["streams"][name] = filtered_streams
|
||||
else:
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' was removed because all sources were restricted (echo/expr/exec or ${{VAR}}). "
|
||||
f"[ERROR] Stream '{name}' was removed because all sources were restricted (echo/expr/exec). "
|
||||
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
|
||||
)
|
||||
del go2rtc_config["streams"][name]
|
||||
|
||||
@@ -3,8 +3,6 @@ location /auth {
|
||||
## Essential Proxy Configuration
|
||||
internal;
|
||||
proxy_pass http://frigate_api/auth;
|
||||
# $go2rtc_stream_proxy is only set by the go2rtc stream locations
|
||||
uninitialized_variable_warn off;
|
||||
|
||||
## Headers
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ RUN --mount=type=bind,from=rk-wheels,source=/rk-wheels,target=/deps/rk-wheels \
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=rk
|
||||
COPY docker/rockchip/COCO /COCO
|
||||
COPY docker/rockchip/conv2rknn.py /opt/conv2rknn.py
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ RUN --mount=type=bind,source=docker/rocm/prune_rocm.sh,target=/deps/prune_rocm.s
|
||||
|
||||
WORKDIR /opt/frigate
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=rocm
|
||||
|
||||
#######################################################################
|
||||
FROM deps-prelim AS rocm-prelim-hsa-override0
|
||||
|
||||
@@ -15,4 +15,3 @@ ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:${INCLUDED_FFMPEG_VERSIO
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=rpi
|
||||
|
||||
@@ -22,7 +22,6 @@ pip3 install --no-deps -U /deps/synap-wheels/*.whl
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=synaptics
|
||||
|
||||
COPY --from=synap1680-wheels /rootfs/usr/local/lib/*.so /usr/lib
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
|
||||
&& pip3 install -U /deps/trt-wheels/*.whl
|
||||
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=tensorrt
|
||||
COPY docker/tensorrt/detector/rootfs/etc/ld.so.conf.d /etc/ld.so.conf.d
|
||||
RUN ldconfig
|
||||
|
||||
|
||||
@@ -151,7 +151,6 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=tensorrt-jp6
|
||||
|
||||
# Fixes "Error importing detector runtime: /usr/lib/aarch64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block"
|
||||
ENV LD_PRELOAD /usr/lib/aarch64-linux-gnu/libstdc++.so.6
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
id: analytics
|
||||
title: Anonymous Analytics
|
||||
---
|
||||
|
||||
import AnalyticsFields from "@site/src/components/AnalyticsFields";
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Frigate can send one anonymous usage report a day. The reports show the maintainers which hardware to support, which features people use, and how releases perform. Sharing is off until you turn it on.
|
||||
|
||||
## Turning it on
|
||||
|
||||
Enable **Share anonymous analytics** at <NavPath path="Settings > System > Telemetry" />, or set it in your config:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
analytics: true
|
||||
```
|
||||
|
||||
The same page has a **Preview the report** button that shows exactly what the next report contains.
|
||||
|
||||
## How it's sent
|
||||
|
||||
- Once a day, as a JSON POST to `https://analytics.frigate.video/report`
|
||||
- The server looks up your country and region from your IP address and never stores the address
|
||||
- Raw reports are kept for 60 days; only aggregate totals are published
|
||||
- A random install ID, stored in `/config/.analytics.json`, keeps your install from being counted twice. Turning sharing off deletes it
|
||||
|
||||
## What's never sent
|
||||
|
||||
- Camera, zone, group, profile, or user names
|
||||
- Object labels, face names, or license plate text
|
||||
- IP addresses, hostnames, URLs, or stream paths
|
||||
- Credentials or API keys
|
||||
- Events, recordings, or anything from them
|
||||
|
||||
## Every field
|
||||
|
||||
Fields marked public appear in the published totals. The machine-readable schema is [frigate-analytics-schema.json](pathname:///frigate-analytics-schema.json).
|
||||
|
||||
<AnalyticsFields />
|
||||
@@ -1218,9 +1218,6 @@ ui:
|
||||
|
||||
# Optional: Telemetry configuration
|
||||
telemetry:
|
||||
# Optional: Share one anonymous usage report a day (default: shown below)
|
||||
# NOTE: See https://docs.frigate.video/configuration/advanced/analytics for what is sent
|
||||
analytics: False
|
||||
# Optional: Enabled network interfaces for bandwidth stats monitoring (default: empty list, let nethogs search all)
|
||||
network_interfaces:
|
||||
- eth
|
||||
|
||||
@@ -398,9 +398,9 @@ To do this:
|
||||
2. Update the `ffmpeg.path` in your Frigate config to `/config/custom-ffmpeg`.
|
||||
3. Restart Frigate and the custom version will be used if the steps above were done correctly.
|
||||
|
||||
Both binaries have to be executable by Frigate's unprivileged runtime user, so `chmod 755` them after extracting. go2rtc runs as its own user, so the build must be world-executable for go2rtc to use it (`ffmpeg:` sources, the birdseye restream and transcoded live streams). The startup ownership sweep runs only once, so anything you add to `/config` later keeps whatever ownership and mode you gave it.
|
||||
Both binaries have to be executable by Frigate's unprivileged runtime user, so `chmod 755` them after extracting. The startup ownership sweep runs only once, so anything you add to `/config` later keeps whatever ownership and mode you gave it.
|
||||
|
||||
There is one exception, and it only affects [`FRIGATE_ROOT_SERVICES`](/configuration/non_root#keeping-individual-services-root) listing `frigate` or `go2rtc`. That mode runs the listed service as root while still handing `/config` to the unprivileged runtime user, so anything running as that user could swap the binary and gain root. A build inside any of Frigate's writable volumes (`/config`, `/media/frigate`, the cache and shm dirs) is ignored by the root service, which uses the bundled one and logs a warning. A service that is not listed keeps using the custom build. Keep the build somewhere root-owned (any absolute `ffmpeg.path` works, so a read-only bind mount such as `/opt/custom-ffmpeg` is enough) if you need both. The default mode and `FRIGATE_RUN_AS_ROOT=true` are unaffected and behave exactly as they always have.
|
||||
There is one exception, and it only affects [`FRIGATE_ROOT_SERVICES`](/configuration/non_root#keeping-individual-services-root) listing `frigate`. That mode runs Frigate as root while still handing `/config` to the unprivileged runtime user, so anything running as that user could swap the binary and gain root. A build inside any of Frigate's writable volumes (`/config`, `/media/frigate`, the cache and shm dirs) is ignored there and the bundled one is used, with a warning in the log. Keep the build somewhere root-owned (any absolute `ffmpeg.path` works, so a read-only bind mount such as `/opt/custom-ffmpeg` is enough) if you need both. The default mode and `FRIGATE_RUN_AS_ROOT=true` are unaffected and behave exactly as they always have.
|
||||
|
||||
### Custom go2rtc version
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Many cheaper or older PTZs may not support this standard. Frigate will report an
|
||||
|
||||
The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/) can provide a starting point to determine a camera's compatibility with Frigate's autotracking. Look to see if a camera lists `PTZRelative`, `PTZRelativePanTilt` and/or `PTZRelativeZoom`. These features are required for autotracking, but some cameras still fail to respond even if they claim support.
|
||||
|
||||
The [CCTV Camera Database](https://www.cctv-database.com/) is useful for researching a specific model.
|
||||
A growing list of cameras and brands that have been reported by users to work with Frigate's autotracking can be found [here](cameras.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -192,7 +192,7 @@ In security and surveillance, it's common to use "spotter" cameras in combinatio
|
||||
|
||||
<FaqItem id="which-ptz-camera-should-i-use-for-autotracking" question="Which PTZ camera should I use for autotracking?">
|
||||
|
||||
See [ONVIF PTZ camera recommendations](cameras.md#onvif-ptz-camera-recommendations) for how to check a camera's autotracking support before buying. Frigate's autotracking was developed with a Dahua SD1A404XB-GNR (now sold as the EmpireTech PTZ1A4M-4X-S2), and Dahua / EmpireTech PTZs are the most consistently reported as working well.
|
||||
See the community-maintained list of [ONVIF PTZ camera recommendations](cameras.md#onvif-ptz-camera-recommendations) for cameras and brands reported to work (and not work) with autotracking. This is not an exhaustive list that is frequently updated, so other cameras not listed may also work well. Frigate's autotracking was developed with a Dahua SD1A404XB-GNR (now sold as the EmpireTech PTZ1A4M-4X-S2), and Dahua / EmpireTech PTZs are the most consistently reported as working well.
|
||||
|
||||
When comparing models:
|
||||
|
||||
@@ -209,7 +209,7 @@ No. Reolink cameras (including the TrackMix series) lack the ONVIF FOV RelativeM
|
||||
|
||||
<FaqItem id="im-seeing-an-error-in-the-logs-that-my-camera-is-still-in-onvif-moving-status-what-does-this-mean" question={"I'm seeing an error in the logs that my camera \"is still in ONVIF 'MOVING' status.\" What does this mean?"}>
|
||||
|
||||
There are two possible known reasons for this (and perhaps others yet unknown): a slow PTZ motor or buggy camera firmware. Frigate uses an ONVIF parameter provided by the camera, `MoveStatus`, to determine when the PTZ's motor is moving or idle. According to some users, Hikvision PTZs (even with the latest firmware), are not updating this value after PTZ movement. Unfortunately there is no workaround to this bug in Hikvision firmware, so autotracking will not function correctly and should be disabled in your config. This may also be the case with other non-Hikvision cameras utilizing Hikvision firmware, such as some Annke models.
|
||||
There are two possible known reasons for this (and perhaps others yet unknown): a slow PTZ motor or buggy camera firmware. Frigate uses an ONVIF parameter provided by the camera, `MoveStatus`, to determine when the PTZ's motor is moving or idle. According to some users, Hikvision PTZs (even with the latest firmware), are not updating this value after PTZ movement. Unfortunately there is no workaround to this bug in Hikvision firmware, so autotracking will not function correctly and should be disabled in your config. This may also be the case with other non-Hikvision cameras utilizing Hikvision firmware, such as some Annke models. In rare cases the vendor may provide fixed firmware on request; for example, Annke has supplied firmware that resolves this for the CZ504 (see the [camera recommendations list](cameras.md#onvif-ptz-camera-recommendations)).
|
||||
|
||||
</FaqItem>
|
||||
|
||||
|
||||
@@ -225,9 +225,36 @@ An ONVIF-capable camera that supports relative movement within the field of view
|
||||
|
||||
## ONVIF PTZ camera recommendations
|
||||
|
||||
Before buying a PTZ camera, check its ONVIF support. The [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/) is the most reliable source. Look at a camera's FeatureList for `PTZRelative`, `PTZRelativePanTilt` and/or `PTZRelativeZoom`. Autotracking requires these features. If they are missing, autotracking will not work, though basic PTZ in the WebUI might. Some cameras still fail to respond even when they claim support. Avoid cameras with no database entry.
|
||||
This list of working and non-working PTZ cameras is based on user feedback. If you'd like to report specific quirks or issues with a manufacturer or camera that would be helpful for other users, open a pull request to add to this list.
|
||||
|
||||
The [CCTV Camera Database](https://www.cctv-database.com/) is an open database of IP camera specifications and is useful for researching a specific model.
|
||||
The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/) can provide a starting point to determine a camera's compatibility with Frigate's autotracking. Look to see if a camera lists `PTZRelative`, `PTZRelativePanTilt` and/or `PTZRelativeZoom`. These features are required for autotracking, but some cameras still fail to respond even if they claim support. If they are missing, autotracking will not work (though basic PTZ in the WebUI might). Avoid cameras with no database entry unless they are confirmed as working below.
|
||||
|
||||
| Brand or specific camera | PTZ Controls | Autotracking | Notes |
|
||||
| ---------------------------- | :----------: | :----------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Amcrest | ✅ | ✅ | ⛔️ Generally, Amcrest should work, but some older models (like the common IP2M-841) don't support autotracking |
|
||||
| Amcrest ASH21 | ✅ | ❌ | ONVIF service port: 80 |
|
||||
| Amcrest IP4M-S2112EW-AI | ✅ | ❌ | FOV relative movement not supported. |
|
||||
| Amcrest IP5M-1190EW | ✅ | ❌ | ONVIF Port: 80. FOV relative movement not supported. |
|
||||
| Annke CZ504 | ✅ | ❌ | |
|
||||
| Axis Q-6155E | ✅ | ❌ | ONVIF service port: 80; Camera does not support MoveStatus. |
|
||||
| Ctronics PTZ | ✅ | ❌ | |
|
||||
| Dahua | ✅ | ✅ | Some low-end Dahuas (lite series, picoo series (commonly), among others) have been reported to not support autotracking. These models usually don't have a four digit model number with chassis prefix and options postfix (e.g. DH-P5AE-PV vs DH-SD49825GB-HNR). |
|
||||
| Dahua DH-SD2A500HB | ✅ | ❌ | |
|
||||
| Dahua DH-SD49825GB-HNR | ✅ | ✅ | |
|
||||
| Dahua DH-P5AE-PV | ❌ | ❌ | |
|
||||
| Foscam | ✅ | ❌ | In general support PTZ, but not relative move. There are no official ONVIF certifications and tests available on the ONVIF Conformant Products Database |
|
||||
| Foscam R5 | ✅ | ❌ | |
|
||||
| Foscam SD4 | ✅ | ❌ | |
|
||||
| Hanwha XNP-6550RH | ✅ | ❌ | |
|
||||
| Hikvision | ✅ | ❌ | Incomplete ONVIF support (MoveStatus won't update even on latest firmware) - reported with HWP-N4215IH-DE and DS-2DE3304W-DE, but likely others |
|
||||
| Hikvision DS-2DE3A404IWG-E/W | ✅ | ✅ | |
|
||||
| Reolink | ✅ | ❌ | |
|
||||
| Speco O8P32X | ✅ | ❌ | |
|
||||
| Sunba 405-D20X | ✅ | ❌ | Incomplete ONVIF support reported on original, and 4k models. All models are suspected incompatible. |
|
||||
| Tapo | ✅ | ❌ | Many models supported, ONVIF Service Port: 2020 |
|
||||
| Uniview IPC672LR-AX4DUPK | ✅ | ❌ | Firmware says FOV relative movement is supported, but camera doesn't actually move when sending ONVIF commands |
|
||||
| Uniview IPC6612SR-X33-VG | ✅ | ✅ | Leave `calibrate_on_startup` as `False`. A user has reported that zooming with `absolute` is working. |
|
||||
| Vikylin PTZ-2804X-I2 | ❌ | ❌ | Incomplete ONVIF support |
|
||||
|
||||
## Setting up camera groups
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ To start using Gemini, you must first get an API key from [Google AI Studio](htt
|
||||
1. Navigate to <NavPath path="Settings > Enrichments > Generative AI" />.
|
||||
- Set **Provider** to `gemini`
|
||||
- Set **API key** to your Gemini API key (or use an environment variable such as `{FRIGATE_GEMINI_API_KEY}`)
|
||||
- Set **Model** to the desired model (e.g., `gemini-3.5-flash-lite`)
|
||||
- Set **Model** to the desired model (e.g., `gemini-2.5-flash`)
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -354,18 +354,12 @@ genai:
|
||||
my_provider:
|
||||
provider: gemini
|
||||
api_key: "{FRIGATE_GEMINI_API_KEY}"
|
||||
model: gemini-3.5-flash-lite
|
||||
model: gemini-2.5-flash
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
:::tip
|
||||
|
||||
Gemini 3.x models charge a (roughly) flat ~1,100 tokens per image regardless of resolution, so a 180p preview frame costs the same as a 480p recording frame. Set the review [image source](/configuration/genai/genai_review#image-source) to `recordings` to send higher quality frames at no extra cost, as long as recordings are enabled for the camera.
|
||||
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
To use a different Gemini-compatible API endpoint, set the `provider_options` with the `base_url` key to your provider's API URL. For example:
|
||||
|
||||
@@ -392,12 +392,7 @@ When your browser runs into problems playing back your camera streams, it will l
|
||||
|
||||
- **stalled**
|
||||
- What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving).
|
||||
- What to try:
|
||||
- This is usually indicative of the browser struggling to decode too many high-resolution streams at once.
|
||||
- Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution.
|
||||
- Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster.
|
||||
- You can also try increasing the timeout value in <NavPath path="Settings > UI" /> .
|
||||
- Also ensure your browser doesn't have JavaScript V8 optimizations disabled ("JavaScript optimisation and security" setting in Chrome-based browsers).
|
||||
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in <NavPath path="Settings > UI" /> .
|
||||
|
||||
- Possible console messages from the player code:
|
||||
- `Buffer time (10 seconds) exceeded, browser may not be playing media correctly.`
|
||||
|
||||
@@ -151,7 +151,7 @@ Along with picking a detector for your hardware, you will choose a model's **inp
|
||||
|
||||
:::tip
|
||||
|
||||
The best detection accuracy comes from a model trained on images that look like what Frigate actually sees: security camera footage cropped to regions of interest. You can train or fine-tune your own model on images like this and run it as a custom model (see the per-detector sections below), but [Frigate+](/plus) makes this much easier by handling the training for you on images submitted from your own cameras. For YOLOv9, the `s` (small) variant at `320x320` resolution is a good place to start, and the larger `c` variant is available for more powerful hardware.
|
||||
The best detection accuracy comes from a model trained on images that look like what Frigate actually sees: security camera footage cropped to regions of interest. You can train or fine-tune your own model on images like this and run it as a custom model (see the per-detector sections below), but [Frigate+](/plus) makes this much easier by handling the training for you on images submitted from your own cameras. For YOLOv9, the `s` (small) variant at `320x320` resolution is a good place to start.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -221,8 +221,6 @@ Any `#` parameter on a bare `rtsp://` source disables the backchannel unless the
|
||||
|
||||
For security reasons, the `echo:`, `expr:`, and `exec:` stream sources are disabled by default in go2rtc. These sources allow arbitrary command execution and can pose security risks if misconfigured.
|
||||
|
||||
go2rtc's `${VAR:default}` variable syntax is also disabled by default in stream sources, since its default can name one of those sources. Use Frigate's [`{FRIGATE_*}` substitution](./config.md#environment-variable-substitution) instead.
|
||||
|
||||
If you attempt to use these sources in your configuration, the streams will be removed and an error message will be printed in the logs.
|
||||
|
||||
To enable these sources, you must set the environment variable `GO2RTC_ALLOW_ARBITRARY_EXEC=true`. This can be done in your Docker Compose file or container environment, or for Home Assistant App users with the `go2rtc_allow_arbitrary_exec` option in the App's configuration. The `environment_vars` section of the Frigate config can't enable it:
|
||||
|
||||
@@ -135,15 +135,6 @@ telemetry:
|
||||
version_check: false
|
||||
```
|
||||
|
||||
### Anonymous Analytics
|
||||
|
||||
If [anonymous analytics](/configuration/advanced/analytics) sharing is turned on, Frigate sends one report a day to `https://analytics.frigate.video`. It's off by default, so no outbound connection happens unless you enable it:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
analytics: true
|
||||
```
|
||||
|
||||
### Push Notifications
|
||||
|
||||
When [notifications](/configuration/notifications) are enabled and users have registered for push notifications in the web UI, Frigate sends push messages through the browser vendor's push service (e.g., Google FCM, Mozilla autopush). This requires internet access from the Frigate server to these push endpoints.
|
||||
@@ -180,7 +171,7 @@ To run Frigate in an air-gapped or offline environment:
|
||||
2. **Pre-download the training base weights**: If you plan to train custom classification models, set `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` before training, then run one training job while online. Without this variable the base weights are cached outside `/config/` and are lost whenever the container is recreated, so a later training run will fail offline. If the machine never has internet access, copy the weights in manually as described below.
|
||||
3. **Disable version check**: Set `telemetry.version_check: false` in your configuration.
|
||||
4. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
|
||||
5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers, and leave anonymous analytics off (its default).
|
||||
5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers.
|
||||
6. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` environment variables to point to local mirrors.
|
||||
|
||||
After these steps, Frigate will operate with no outbound internet connections.
|
||||
|
||||
@@ -47,10 +47,6 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht
|
||||
|
||||
[Frigate telegram](https://github.com/OldTyT/frigate-telegram) makes it possible to send events from Frigate to Telegram. Events are sent as a message with a text description, video, and thumbnail.
|
||||
|
||||
## [FrigatePiP](https://github.com/maciejgornicz/frigate-pip)
|
||||
|
||||
[FrigatePiP](https://github.com/maciejgornicz/frigate-pip) pops up a small picture-in-picture window with a camera's live video in a corner of the screen when Frigate detects an object, and closes it when the activity ends. It runs as a Windows tray app and on Linux (desktop windows, a console framebuffer, a terminal or tmux popups), receives events over Frigate's WebSocket or MQTT and plays the go2rtc RTSP stream with hardware decoding and camera audio.
|
||||
|
||||
## [kiosk-monitor](https://github.com/extremeshok/kiosk-monitor)
|
||||
|
||||
[kiosk-monitor](https://github.com/extremeshok/kiosk-monitor) is a Raspberry Pi watchdog that runs Chromium fullscreen on a Frigate dashboard (optionally with VLC on a second monitor for an RTSP camera stream), auto-restarts on frozen screens or unreachable URLs, and ships a Birdseye-aware Chromium helper that auto-sizes the grid to the display.
|
||||
@@ -59,10 +55,6 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht
|
||||
|
||||
[Periscope](https://github.com/maksz42/periscope) is a lightweight Android app that turns old devices into live viewers for Frigate. It works on Android 2.2 and above, including Android TV. It supports authentication and HTTPS.
|
||||
|
||||
## [reostream](https://github.com/VoltTech21/reostream)
|
||||
|
||||
[reostream](https://github.com/VoltTech21/reostream) is a proxy for Reolink cameras that pulls video over Reolink's own protocol (the one the Reolink app and NVR use) instead of the camera's RTSP server, and serves it back as RTSP for go2rtc and Frigate. It helps models whose RTSP stream smears, drops frames or loses full resolution, and supports two-way talk.
|
||||
|
||||
## [Scrypted - Frigate bridge plugin](https://github.com/apocaliss92/scrypted-frigate-bridge)
|
||||
|
||||
[Scrypted - Frigate bridge](https://github.com/apocaliss92/scrypted-frigate-bridge) is a plugin that allows you to ingest Frigate detections, motion, videoclips on Scrypted as well as provide templates to export rebroadcast configurations on Frigate.
|
||||
|
||||
@@ -25,12 +25,6 @@ If you have an active Frigate+ subscription, new uploads will be scanned for the
|
||||
|
||||
Suggestions are converted to labels when saving, so you should remove any errant suggestions. There is already some logic designed to avoid duplicate labels, but you may still occasionally see some duplicate suggestions. You should keep the most accurate bounding box and delete any duplicates so that you have just one label per object remaining.
|
||||
|
||||
## Reviewing annotations with Patches
|
||||
|
||||
The Patches tab on the Images page shows a cropped view of every annotation for a label in a grid. Scanning the crops makes it much easier to spot a mislabeled object than paging through full images one at a time. Pick a label to get started, and optionally narrow the results to a single camera. Clicking a patch opens that image in the editor so you can fix the annotation.
|
||||
|
||||
Patches only include annotations from images you have already verified.
|
||||
|
||||
## False positive labels
|
||||
|
||||
False positives will be shown with a red box and the label will have a strike through. These can't be adjusted, but they can be deleted if you accidentally submit a true positive as a false positive from Frigate.
|
||||
@@ -57,12 +51,3 @@ Misidentified objects should have a correct label added. For example, if a perso
|
||||
| `scrollwheel` | Zoom in/out |
|
||||
| `f` | Hide/show all but current box |
|
||||
| `spacebar` | Verify and save |
|
||||
|
||||
## Deleting images
|
||||
|
||||
In most cases, you should keep your existing images. Removing training data usually makes accuracy worse, not better, so you shouldn't delete images just because you think you made some annotation mistakes. Deleting images makes sense when you move to a new location, or significantly upgrade or reposition your cameras.
|
||||
|
||||
- To delete all images for a camera, use the **Delete Images** button for that camera on the Cameras page.
|
||||
- To delete all images in your account, use **Delete All Images** on the Settings page.
|
||||
|
||||
Deletions run in the background, and their progress is shown in the bulk operations panel. Deleted images cannot be recovered and are no longer used to train your models.
|
||||
|
||||
@@ -26,9 +26,7 @@ Yes. Subscriptions to Frigate+ provide access to the infrastructure used to trai
|
||||
### Can I use Frigate+ models commercially?
|
||||
|
||||
A standard subscription covers use on camera systems you own or operate, including for your business. A shop, restaurant, warehouse, or office running Frigate+ at its own locations (including multiple locations) is exactly the kind of use the subscription is for.
|
||||
|
||||
What the standard subscription does not cover is using Frigate+ models to provide a product or service to others. If you're deploying models at your customers' sites, bundling them with hardware you sell, or running them as part of a hosted or managed service, even if your customers never receive the model files themselves, you'll need a commercial license.
|
||||
|
||||
Note that professional installers are fine under standard subscriptions when each customer holds their own Frigate+ subscription. The commercial license is for cases where your license powers your customers' sites.
|
||||
|
||||
### Why can't I submit images to Frigate+?
|
||||
|
||||
@@ -17,7 +17,7 @@ For more detailed recommendations, you can refer to the docs on [annotating](./a
|
||||
|
||||
## Step 2: Submit a model request
|
||||
|
||||
Once you have an initial set of verified images, you can request a model on the Models page. For guidance on choosing a model type, refer to [this part of the documentation](./index.md#available-model-types). If you are unsure which type to request, you can test the base model for each version from the "Base Models" tab. For most hardware, `yolov9` is recommended; `mobiledet` is a legacy model type and will be removed in 2027. Each model request requires 1 of the 12 trainings that you receive with your annual subscription. This model will support all [label types available](./index.md#available-label-types) even if you do not submit any examples for those labels. Model creation can take up to 36 hours.
|
||||
Once you have an initial set of verified images, you can request a model on the Models page. For guidance on choosing a model type, refer to [this part of the documentation](./index.md#available-model-types). If you are unsure which type to request, you can test the base model for each version from the "Base Models" tab. Each model request requires 1 of the 12 trainings that you receive with your annual subscription. This model will support all [label types available](./index.md#available-label-types) even if you do not submit any examples for those labels. Model creation can take up to 36 hours.
|
||||

|
||||
|
||||
## Step 3: Set your model
|
||||
|
||||
+17
-33
@@ -5,41 +5,25 @@ title: Models
|
||||
|
||||
<a href="https://frigate.video/plus" target="_blank" rel="nofollow">Frigate+</a> offers models trained on images submitted by Frigate+ users from their security cameras and is specifically designed for the way Frigate NVR analyzes video footage. These models offer higher accuracy with less resources. The images you upload are used to fine tune a base model trained from images uploaded by all Frigate+ users. This fine tuning process results in a model that is optimized for accuracy in your specific conditions.
|
||||
|
||||
With a subscription, 12 model trainings to fine tune your model per year are included. In addition, you will have access to any base models published while your subscription is active. If you cancel your subscription, you will retain access to any trained and base models in your account. An active subscription is required to submit model requests or purchase additional trainings. New base models are published twice per year.
|
||||
With a subscription, 12 model trainings to fine tune your model per year are included. In addition, you will have access to any base models published while your subscription is active. If you cancel your subscription, you will retain access to any trained and base models in your account. An active subscription is required to submit model requests or purchase additional trainings. New base models are published quarterly with target dates of January 15th, April 15th, July 15th, and October 15th.
|
||||
|
||||
Information on how to integrate Frigate+ with Frigate can be found in the [integration docs](../integrations/plus.md).
|
||||
|
||||
## Available model types
|
||||
|
||||
There are three model types offered in Frigate+, `yolov9`, `yolonas`, and `mobiledet` (legacy). All of these models are object detection models and are trained to detect the same set of labels [listed below](#available-label-types).
|
||||
There are three model types offered in Frigate+, `mobiledet`, `yolonas`, and `yolov9`. All of these models are object detection models and are trained to detect the same set of labels [listed below](#available-label-types).
|
||||
|
||||
Not all model types are supported by all detectors, so it's important to choose a model type to match your detector as shown in the table under [supported detector types](#supported-detector-types). You can test model types for compatibility and speed on your hardware by using the base models.
|
||||
|
||||
| Model Type | Description |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `yolov9` | A leading SOTA (state of the art) object detection model with similar performance to yolonas, but on a wider range of hardware options. Runs on most hardware. |
|
||||
| `mobiledet` | Based on the same architecture as the default model included with Frigate. Runs on Google Coral devices and CPUs. |
|
||||
| `yolonas` | A newer architecture that offers slightly higher accuracy and improved detection of small objects. Runs on Intel, NVidia GPUs, and AMD GPUs. |
|
||||
| `mobiledet` | Legacy. Based on the same architecture as the default model included with Frigate. Runs on Google Coral devices and CPUs. |
|
||||
|
||||
:::warning
|
||||
|
||||
`mobiledet` is a legacy model type. Base model 2026.3 is the last base model update for `mobiledet`, and it is planned to be removed from Frigate+ in 2027. If you are using `mobiledet` with a Google Coral, request a `yolov9` model with the `edgetpu` variant instead.
|
||||
|
||||
:::
|
||||
| `yolov9` | A leading SOTA (state of the art) object detection model with similar performance to yolonas, but on a wider range of hardware options. Runs on most hardware. |
|
||||
|
||||
### YOLOv9 Details
|
||||
|
||||
YOLOv9 models are available in `t`, `s`, `c`, and `edgetpu` variants, and an `m` size is coming soon. When requesting a `yolov9` model, you will be prompted to choose a variant. If you want the model to be compatible with a Google Coral, you will need to choose the `edgetpu` variant, which requires Frigate 0.17 or later. If you are unsure what variant to choose, you should perform some tests with the base models to find the performance level that suits you. The `s` size is most similar to the current `yolonas` models in terms of inference times and accuracy, and a good place to start is the `320x320` resolution model for `yolov9s`. The `c` size is larger and offers higher accuracy at the cost of slower inference, so it is best suited to more powerful hardware such as GPUs.
|
||||
|
||||
| Variant | Size | Parameters | Speed and accuracy | Best suited for |
|
||||
| --------- | -------------------- | ---------- | ----------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `t` | Tiny | ~2.0M | Fastest inference, lowest accuracy | Low power hardware or a large number of cameras |
|
||||
| `s` | Small | ~7.1M | Balanced. Similar inference times and accuracy to `yolonas` | Most users. Start with `320x320` |
|
||||
| `m` | Medium (coming soon) | ~20.0M | Slower than `s` with higher accuracy | Mid-range GPUs and NPUs |
|
||||
| `c` | Compact | ~25.3M | Highest accuracy, slowest inference | More powerful hardware such as GPUs |
|
||||
| `edgetpu` | N/A | N/A | Optimized to run on the Coral's Edge TPU | Google Coral devices only (requires Frigate 0.17+) |
|
||||
|
||||
Parameter counts are for the standard YOLOv9 architectures. Actual inference times depend on your hardware and the input resolution you choose, so test with the base models before requesting a model.
|
||||
YOLOv9 models are available in `s`, `t`, `edgetpu` variants. When requesting a `yolov9` model, you will be prompted to choose a variant. If you want the model to be compatible with a Google Coral, you will need to choose the `edgetpu` variant. If you are unsure what variant to choose, you should perform some tests with the base models to find the performance level that suits you. The `s` size is most similar to the current `yolonas` models in terms of inference times and accuracy, and a good place to start is the `320x320` resolution model for `yolov9s`.
|
||||
|
||||
:::info
|
||||
|
||||
@@ -49,7 +33,7 @@ When switching to YOLOv9, you may need to adjust your thresholds for some object
|
||||
|
||||
#### Hailo Support
|
||||
|
||||
If you have a Hailo device, you will need to specify the hardware you have when submitting a model request because they are not cross compatible. Hailo models are available for the `t`, `s`, and `c` variants on Hailo8, Hailo8L, and Hailo8R devices. Please test using the available base models before submitting your model request.
|
||||
If you have a Hailo device, you will need to specify the hardware you have when submitting a model request because they are not cross compatible. Please test using the available base models before submitting your model request.
|
||||
|
||||
#### Rockchip (RKNN) Support
|
||||
|
||||
@@ -59,15 +43,15 @@ Rockchip models are automatically converted as of 0.17. For 0.16, YOLOv9 onnx mo
|
||||
|
||||
Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), ONNX (`onnx`), Hailo (`hailo`), and Rockchip (`rknn`) detectors.
|
||||
|
||||
| Hardware | Recommended Detector Type | Recommended Model Type |
|
||||
| -------------------------------------------------------------------------------- | --------------------------- | ---------------------- |
|
||||
| [CPU](/configuration/object_detectors.md#cpu-detector-not-recommended) | `openvino (or alternative)` | `yolov9` |
|
||||
| [Coral (all form factors)](/configuration/object_detectors.md#edge-tpu-detector) | `edgetpu` | `yolov9` |
|
||||
| [Intel](/configuration/object_detectors.md#openvino-detector) | `openvino` | `yolov9` |
|
||||
| [NVidia GPU](/configuration/object_detectors#onnx) | `onnx` | `yolov9` |
|
||||
| [AMD ROCm GPU](/configuration/object_detectors#amdrocm-gpu-detector) | `onnx` | `yolov9` |
|
||||
| [Hailo8/Hailo8L/Hailo8R](/configuration/object_detectors#hailo) | `hailo` | `yolov9` |
|
||||
| [Rockchip NPU](/configuration/object_detectors#rockchip-platform) | `rknn` | `yolov9` |
|
||||
| Hardware | Recommended Detector Type | Recommended Model Type |
|
||||
| -------------------------------------------------------------------------------- | ------------------------- | ---------------------- |
|
||||
| [CPU](/configuration/object_detectors.md#cpu-detector-not-recommended) | `cpu` | `mobiledet` |
|
||||
| [Coral (all form factors)](/configuration/object_detectors.md#edge-tpu-detector) | `edgetpu` | `yolov9` |
|
||||
| [Intel](/configuration/object_detectors.md#openvino-detector) | `openvino` | `yolov9` |
|
||||
| [NVidia GPU](/configuration/object_detectors#onnx) | `onnx` | `yolov9` |
|
||||
| [AMD ROCm GPU](/configuration/object_detectors#amdrocm-gpu-detector) | `onnx` | `yolov9` |
|
||||
| [Hailo8/Hailo8L/Hailo8R](/configuration/object_detectors#hailo) | `hailo` | `yolov9` |
|
||||
| [Rockchip NPU](/configuration/object_detectors#rockchip-platform) | `rknn` | `yolov9` |
|
||||
|
||||
## Improving your model
|
||||
|
||||
@@ -82,7 +66,7 @@ Some users may find that Frigate+ models result in more false positives initiall
|
||||
Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported:
|
||||
|
||||
- **People**: `person`, `face`, `baby`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage_truck`, `license_plate`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate`
|
||||
- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail`
|
||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`, `possum`, `rodent`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller`
|
||||
@@ -97,7 +81,7 @@ Where possible, these labels are mapped to existing labels during training. For
|
||||
|
||||
- **Vehicles**: `tractor`, `golf_cart`, `bus`, `airplane`, `helicopter`, `rickshaw`, `scooter`
|
||||
- **Delivery Logos**: `bpost`, `auspost`, `aramex`, `transoflex`, `parcelforce`, `hermes`, `cargus`, `fan_courier`, `sameday`, `la_poste`
|
||||
- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet`, `chipmunk`
|
||||
- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet`
|
||||
- **Other**: `sports_ball`, `drone`, `lawnmower`
|
||||
|
||||
Candidate labels are not available for automatic suggestions.
|
||||
|
||||
@@ -170,7 +170,7 @@ Open one of the tracked objects in Explore → **Tracking Details**. If the dete
|
||||
What helps:
|
||||
|
||||
- **Improve the view**: even a small angle change that gets more of the car visible could lift scores enough to stabilize tracking.
|
||||
- **Use a more accurate model**: switching from `mobiledet` to `yolov9`, or stepping up to a larger variant like `yolov9-s` over `yolov9-t` (or `yolov9-c` on more powerful hardware), can help (at the cost of inference time, and still on the COCO dataset). The biggest gains usually come from fine-tuning a model on images from your own cameras so it learns your specific scene. [Frigate+](https://frigate.video/plus) is a paid option that does this - models are trained on security-camera footage and can be fine-tuned on images you submit from your own setup.
|
||||
- **Use a more accurate model**: switching from `mobiledet` to `yolov9`, or stepping up to a larger variant like `yolov9-s` over `yolov9-t`, can help (at the cost of inference time, and still on the COCO dataset). The biggest gains usually come from fine-tuning a model on images from your own cameras so it learns your specific scene. [Frigate+](https://frigate.video/plus) is a paid option that does this - models are trained on security-camera footage and can be fine-tuned on images you submit from your own setup.
|
||||
- **Don't set `detect -> stationary -> max_frames` for `car`**: it artificially ends tracking and forces re-detection as a new object. See [Stationary Objects](../configuration/stationary_objects.md).
|
||||
- **Restrict alerts to the areas you care about** with `required_zones`. See [Zones](../configuration/zones.md#restricting-alerts-and-detections-to-specific-zones). Make sure those zones use the default `loitering_time: 0` unless you specifically want the review item to stay open until the car leaves.
|
||||
- **Filter impossible locations** with [object filter masks](../configuration/masks.md#object-filter-masks) if cars are being detected on rooftops, treetops, etc.
|
||||
|
||||
@@ -130,7 +130,6 @@ const sidebars: SidebarsConfig = {
|
||||
label: "Advanced Configuration",
|
||||
items: [
|
||||
"configuration/advanced/system",
|
||||
"configuration/advanced/analytics",
|
||||
"configuration/advanced/reference",
|
||||
{
|
||||
type: "link",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from "react";
|
||||
import schema from "@site/static/frigate-analytics-schema.json";
|
||||
|
||||
function resolve(node) {
|
||||
if (!node) return node;
|
||||
if (node.$ref) return schema.$defs[node.$ref.split("/").pop()];
|
||||
if (node.anyOf) {
|
||||
const inner = node.anyOf.find((option) => option.type !== "null");
|
||||
return inner ? resolve(inner) : node;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function rows(properties, prefix = "") {
|
||||
return Object.entries(properties).flatMap(([name, field]) => {
|
||||
const path = prefix ? `${prefix}.${name}` : name;
|
||||
const row = {
|
||||
path,
|
||||
description: field.description,
|
||||
isPublic: field["x-public"],
|
||||
};
|
||||
const target = resolve(field);
|
||||
|
||||
if (target?.properties) return [row, ...rows(target.properties, path)];
|
||||
|
||||
const values = resolve(target?.additionalProperties);
|
||||
if (values?.properties)
|
||||
return [row, ...rows(values.properties, `${path}.<key>`)];
|
||||
|
||||
const items = resolve(target?.items);
|
||||
if (items?.properties) return [row, ...rows(items.properties, `${path}[]`)];
|
||||
|
||||
return [row];
|
||||
});
|
||||
}
|
||||
|
||||
export default function AnalyticsFields() {
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Description</th>
|
||||
<th>Public</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows(schema.properties).map((row) => (
|
||||
<tr key={row.path}>
|
||||
<td>
|
||||
<code>{row.path}</code>
|
||||
</td>
|
||||
<td>{row.description}</td>
|
||||
<td>{row.isPublic ? "Yes" : "No"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
-1492
File diff suppressed because it is too large
Load Diff
Vendored
-19
@@ -10,25 +10,6 @@ servers:
|
||||
- url: https://demo.frigate.video/api
|
||||
- url: http://localhost:5001/api
|
||||
paths:
|
||||
/analytics/preview:
|
||||
get:
|
||||
tags:
|
||||
- Analytics
|
||||
summary: Get Analytics Preview
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get the analytics report Frigate would send next, without sending it.
|
||||
operationId: get_analytics_preview_analytics_preview_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/auth/first_time_login:
|
||||
get:
|
||||
tags:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Opt-in anonymous analytics reports."""
|
||||
@@ -1 +0,0 @@
|
||||
"""One collector per report section."""
|
||||
@@ -1,238 +0,0 @@
|
||||
"""Cameras section: counts and histograms across cameras, never per camera."""
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from frigate.analytics.collectors.common import closed, histogram
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import (
|
||||
CamerasSection,
|
||||
ConnectionQuality,
|
||||
FpsBucket,
|
||||
HeightBucket,
|
||||
HwaccelKey,
|
||||
InputPresetKey,
|
||||
RetainBucket,
|
||||
RetainDays,
|
||||
)
|
||||
from frigate.config import CameraConfig
|
||||
from frigate.config.camera.camera import CameraTypeEnum
|
||||
from frigate.config.camera.ffmpeg import CameraInput, CameraRoleEnum
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
|
||||
# the upper edge of every height bucket but the last
|
||||
HEIGHT_BUCKETS = (
|
||||
(360, HeightBucket.le_360),
|
||||
(540, HeightBucket.h480),
|
||||
(900, HeightBucket.h720),
|
||||
(1260, HeightBucket.h1080),
|
||||
(1800, HeightBucket.h1440),
|
||||
)
|
||||
RESTREAM_HOSTS = frozenset({"127.0.0.1", "localhost"})
|
||||
RESTREAM_PORT = 8554
|
||||
QUALITIES = frozenset(ConnectionQuality)
|
||||
|
||||
|
||||
def height_bucket(height: int) -> HeightBucket:
|
||||
"""The nearest standard resolution bucket for a detect height."""
|
||||
for limit, bucket in HEIGHT_BUCKETS:
|
||||
if height <= limit:
|
||||
return bucket
|
||||
|
||||
return HeightBucket.ge_2160
|
||||
|
||||
|
||||
def fps_bucket(fps: int) -> FpsBucket:
|
||||
"""The bucket for a detect fps."""
|
||||
if fps <= 5:
|
||||
return FpsBucket.le_5
|
||||
|
||||
if fps <= 10:
|
||||
return FpsBucket.f6_10
|
||||
|
||||
return FpsBucket.gt_10
|
||||
|
||||
|
||||
def retain_bucket(days: float) -> RetainBucket:
|
||||
"""The bucket for a retention period in days."""
|
||||
if days <= 0:
|
||||
return RetainBucket.zero
|
||||
|
||||
if days <= 7:
|
||||
return RetainBucket.d1_7
|
||||
|
||||
if days <= 30:
|
||||
return RetainBucket.d8_30
|
||||
|
||||
return RetainBucket.gt_30
|
||||
|
||||
|
||||
def preset_key(args: str | list[str], enum: Any) -> Any:
|
||||
"""The preset's name, custom for hand written args, or none."""
|
||||
if not args:
|
||||
return enum("none")
|
||||
|
||||
if isinstance(args, str) and args.startswith("preset-"):
|
||||
return closed(enum, args.removeprefix("preset-"), enum("custom"))
|
||||
|
||||
return enum("custom")
|
||||
|
||||
|
||||
def is_restream(path: str) -> bool:
|
||||
"""Whether an input reads from the local go2rtc restream."""
|
||||
try:
|
||||
url = urlsplit(path)
|
||||
return url.hostname in RESTREAM_HOSTS and url.port == RESTREAM_PORT
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def role_input(camera: CameraConfig, role: CameraRoleEnum) -> CameraInput | None:
|
||||
"""The camera's first input with the given role."""
|
||||
return next((i for i in camera.ffmpeg.inputs if role in i.roles), None)
|
||||
|
||||
|
||||
def object_masks(camera: CameraConfig) -> int:
|
||||
"""Camera and per-label object masks, minus the global_<id> filter copies."""
|
||||
own = sum(1 for mask in camera.objects.mask.values() if mask is not None)
|
||||
per_label = sum(
|
||||
1
|
||||
for label_filter in camera.objects.filters.values()
|
||||
for mask_id, mask in label_filter.mask.items()
|
||||
if mask is not None and not mask_id.startswith("global_")
|
||||
)
|
||||
return own + per_label
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> CamerasSection:
|
||||
"""Count camera settings across the cameras enabled in config."""
|
||||
cameras = {
|
||||
name: camera
|
||||
for name, camera in ctx.config.cameras.items()
|
||||
if not name.startswith(REPLAY_CAMERA_PREFIX)
|
||||
}
|
||||
camera_stats = ctx.stats.get("cameras", {})
|
||||
|
||||
# cameras disabled in config are parked, so they'd skew feature adoption
|
||||
active = [
|
||||
(name, camera) for name, camera in cameras.items() if camera.enabled_in_config
|
||||
]
|
||||
|
||||
flags: Counter[str] = Counter()
|
||||
types: Counter[CameraTypeEnum] = Counter()
|
||||
heights: Counter[HeightBucket] = Counter()
|
||||
fps: Counter[FpsBucket] = Counter()
|
||||
hwaccel: Counter[Any] = Counter()
|
||||
input_presets: Counter[Any] = Counter()
|
||||
quality: Counter[ConnectionQuality] = Counter()
|
||||
retain: dict[str, Counter[RetainBucket]] = {
|
||||
period: Counter() for period in ("continuous", "motion", "alerts", "detections")
|
||||
}
|
||||
|
||||
for name, camera in active:
|
||||
detect_input = role_input(camera, CameraRoleEnum.detect)
|
||||
record_input = role_input(camera, CameraRoleEnum.record)
|
||||
|
||||
# config validation requires a detect input, so this never skips
|
||||
if detect_input is None:
|
||||
continue
|
||||
|
||||
types[camera.type] += 1
|
||||
fps[fps_bucket(camera.detect.fps)] += 1
|
||||
hwaccel[
|
||||
preset_key(
|
||||
detect_input.hwaccel_args or camera.ffmpeg.hwaccel_args, HwaccelKey
|
||||
)
|
||||
] += 1
|
||||
input_presets[
|
||||
preset_key(
|
||||
detect_input.input_args or camera.ffmpeg.input_args, InputPresetKey
|
||||
)
|
||||
] += 1
|
||||
|
||||
if camera.detect.height:
|
||||
heights[height_bucket(camera.detect.height)] += 1
|
||||
|
||||
state = camera_stats.get(name, {}).get("connection_quality")
|
||||
|
||||
# a camera turned off at runtime reports unusable
|
||||
if camera.enabled and state in QUALITIES:
|
||||
quality[ConnectionQuality(state)] += 1
|
||||
|
||||
if camera.record.enabled:
|
||||
retain["continuous"][retain_bucket(camera.record.continuous.days)] += 1
|
||||
retain["motion"][retain_bucket(camera.record.motion.days)] += 1
|
||||
retain["alerts"][retain_bucket(camera.record.alerts.retain.days)] += 1
|
||||
retain["detections"][
|
||||
retain_bucket(camera.record.detections.retain.days)
|
||||
] += 1
|
||||
|
||||
zones = len(camera.zones)
|
||||
flags["go2rtc_restream"] += any(
|
||||
is_restream(i.path) for i in camera.ffmpeg.inputs
|
||||
)
|
||||
flags["separate_detect_stream"] += (
|
||||
record_input is not None and record_input.path != detect_input.path
|
||||
)
|
||||
flags["detect"] += camera.detect.enabled
|
||||
flags["record"] += camera.record.enabled
|
||||
flags["sub_stream_record"] += (
|
||||
camera.record.enabled and camera.record.sub.enabled
|
||||
)
|
||||
flags["snapshots"] += camera.snapshots.enabled
|
||||
flags["audio"] += camera.audio.enabled
|
||||
flags["audio_transcription"] += (
|
||||
camera.audio.enabled and camera.audio_transcription.enabled
|
||||
)
|
||||
flags["birdseye"] += camera.birdseye.enabled
|
||||
flags["onvif"] += bool(camera.onvif.host)
|
||||
flags["autotracking"] += camera.onvif.autotracking.enabled
|
||||
flags["face_recognition"] += camera.face_recognition.enabled
|
||||
flags["lpr"] += camera.lpr.enabled
|
||||
flags["review_genai"] += camera.review.genai.enabled
|
||||
flags["object_genai"] += camera.objects.genai.enabled
|
||||
flags["notifications"] += camera.notifications.enabled
|
||||
flags["zones"] += zones
|
||||
flags["cameras_with_zones"] += zones > 0
|
||||
flags["motion_masks"] += sum(
|
||||
1 for mask in camera.motion.mask.values() if mask is not None
|
||||
)
|
||||
flags["object_masks"] += object_masks(camera)
|
||||
|
||||
return CamerasSection(
|
||||
total=len(cameras),
|
||||
enabled=len(active),
|
||||
types=histogram(types),
|
||||
detect_height=histogram(heights),
|
||||
detect_fps=histogram(fps),
|
||||
hwaccel=histogram(hwaccel),
|
||||
input_preset=histogram(input_presets),
|
||||
go2rtc_restream=flags["go2rtc_restream"],
|
||||
separate_detect_stream=flags["separate_detect_stream"],
|
||||
detect=flags["detect"],
|
||||
record=flags["record"],
|
||||
sub_stream_record=flags["sub_stream_record"],
|
||||
snapshots=flags["snapshots"],
|
||||
audio=flags["audio"],
|
||||
audio_transcription=flags["audio_transcription"],
|
||||
birdseye=flags["birdseye"],
|
||||
onvif=flags["onvif"],
|
||||
autotracking=flags["autotracking"],
|
||||
face_recognition=flags["face_recognition"],
|
||||
lpr=flags["lpr"],
|
||||
review_genai=flags["review_genai"],
|
||||
object_genai=flags["object_genai"],
|
||||
notifications=flags["notifications"],
|
||||
zones=flags["zones"],
|
||||
cameras_with_zones=flags["cameras_with_zones"],
|
||||
motion_masks=flags["motion_masks"],
|
||||
object_masks=flags["object_masks"],
|
||||
connection_quality=histogram(quality),
|
||||
retain_days=RetainDays(
|
||||
continuous=histogram(retain["continuous"]),
|
||||
motion=histogram(retain["motion"]),
|
||||
alerts=histogram(retain["alerts"]),
|
||||
detections=histogram(retain["detections"]),
|
||||
),
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Helpers the section collectors share."""
|
||||
|
||||
import math
|
||||
from collections import Counter
|
||||
from enum import Enum
|
||||
from typing import Any, TypeVar
|
||||
|
||||
E = TypeVar("E", bound=Enum)
|
||||
|
||||
|
||||
def closed(enum: type[E], value: Any, fallback: E) -> E:
|
||||
"""The member for a value, or the fallback for one the enum doesn't know."""
|
||||
try:
|
||||
return enum(str(value))
|
||||
except ValueError:
|
||||
return fallback
|
||||
|
||||
|
||||
def rate(value: Any) -> float:
|
||||
"""A finite, non-negative number rounded to 2 decimals, else 0.
|
||||
|
||||
A NaN would serialize as null and fail the schema's number type.
|
||||
"""
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
if not math.isfinite(number):
|
||||
return 0.0
|
||||
|
||||
return round(max(number, 0.0), 2)
|
||||
|
||||
|
||||
def histogram(counter: "Counter[E]") -> dict[E, int]:
|
||||
"""Drop the empty buckets, since an absent key means zero."""
|
||||
return {key: total for key, total in counter.items() if total > 0}
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Detection section: models, the detectors they run on, and inference speed."""
|
||||
|
||||
import os
|
||||
|
||||
from frigate.analytics.collectors.common import rate
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import DetectionModel, DetectionSection, ModelSource
|
||||
from frigate.config.config import DEFAULT_MODEL
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.detectors.detector_config import DEFAULT_SCENE
|
||||
from frigate.detectors.detector_types import DetectorTypeEnum
|
||||
from frigate.detectors.device import runner_names
|
||||
|
||||
# the paths FrigateConfig fills in for a model that sets none
|
||||
BUNDLED_MODEL_PATHS = frozenset(
|
||||
{"/cpu_model.tflite", "/edgetpu_model.tflite", str(DEFAULT_MODEL["path"])}
|
||||
)
|
||||
|
||||
|
||||
def model_source(path: str | None) -> ModelSource:
|
||||
"""Default, Frigate+ (a cached model next to its info file), or custom.
|
||||
|
||||
Parsing rewrites plus://<id> to the model cache, so the prefix is gone by now.
|
||||
"""
|
||||
if path is None or path in BUNDLED_MODEL_PATHS:
|
||||
return ModelSource.default
|
||||
|
||||
if path.startswith(f"{MODEL_CACHE_DIR}/") and os.path.isfile(f"{path}.json"):
|
||||
return ModelSource.plus
|
||||
|
||||
return ModelSource.custom
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> DetectionSection:
|
||||
"""Describe each configured model and its measured inference speed."""
|
||||
config = ctx.config
|
||||
detectors = ctx.stats.get("detectors", {})
|
||||
model_specs = [(model, config.devices_for_model(model)) for model in config.models]
|
||||
|
||||
# FrigateApp.start_detectors names the processes in this same order
|
||||
names = iter(runner_names([spec for _, specs in model_specs for spec in specs]))
|
||||
models: list[DetectionModel] = []
|
||||
|
||||
for model, specs in model_specs:
|
||||
speeds: list[float] = []
|
||||
|
||||
for _ in specs:
|
||||
speed = detectors.get(next(names), {}).get("inference_speed")
|
||||
|
||||
if isinstance(speed, int | float) and speed > 0:
|
||||
speeds.append(float(speed))
|
||||
|
||||
models.append(
|
||||
DetectionModel(
|
||||
default=model.scene == DEFAULT_SCENE,
|
||||
detector=DetectorTypeEnum(specs[0].detector),
|
||||
devices=len(specs),
|
||||
model_type=model.model_type,
|
||||
input=f"{model.width}x{model.height}",
|
||||
source=model_source(model.path),
|
||||
inference_ms=rate(sum(speeds) / len(speeds)) if speeds else None,
|
||||
)
|
||||
)
|
||||
|
||||
return DetectionSection(
|
||||
models=models,
|
||||
detection_fps=rate(ctx.stats.get("detection_fps")),
|
||||
skipped_fps=rate(ctx.stats.get("skipped_fps")),
|
||||
)
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Features section: enrichments, GenAI, integrations, and users."""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from frigate.analytics.collectors.common import closed, histogram
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import (
|
||||
BirdseyeUsage,
|
||||
ClassificationUsage,
|
||||
EnrichmentDevice,
|
||||
EnrichmentUsage,
|
||||
FeaturesSection,
|
||||
GenAIUsage,
|
||||
SemanticSearchModel,
|
||||
SemanticSearchUsage,
|
||||
TranscriptionModel,
|
||||
TranscriptionUsage,
|
||||
UserRole,
|
||||
)
|
||||
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import User
|
||||
|
||||
RUNTIME_DEVICES = {
|
||||
"cpu": EnrichmentDevice.cpu,
|
||||
"cuda": EnrichmentDevice.cuda,
|
||||
"tensorrt": EnrichmentDevice.tensorrt,
|
||||
"migraphx": EnrichmentDevice.migraphx,
|
||||
}
|
||||
OPENVINO_DEVICES = {
|
||||
"cpu": EnrichmentDevice.openvino_cpu,
|
||||
"gpu": EnrichmentDevice.openvino_gpu,
|
||||
"npu": EnrichmentDevice.openvino_npu,
|
||||
}
|
||||
|
||||
|
||||
def enrichment_device(label: object) -> EnrichmentDevice | None:
|
||||
"""Map a runner's device label, like "CUDA" or "OpenVINO GPU.0,CPU"."""
|
||||
if not isinstance(label, str) or not label:
|
||||
return None
|
||||
|
||||
runtime, _, target = label.partition(" ")
|
||||
|
||||
if runtime == "OpenVINO":
|
||||
# AUTO reports "(CPU)" while it warms up on the CPU
|
||||
first = target.split(",")[0].split(".")[0].strip("() ").lower()
|
||||
return OPENVINO_DEVICES.get(first, EnrichmentDevice.other)
|
||||
|
||||
return RUNTIME_DEVICES.get(label.lower(), EnrichmentDevice.other)
|
||||
|
||||
|
||||
def model_name(model: object) -> str | None:
|
||||
"""The model's config value as a string."""
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
return str(getattr(model, "value", model))
|
||||
|
||||
|
||||
def semantic_model(model: object) -> SemanticSearchModel | None:
|
||||
"""A built-in Jina model, or genai for any GenAI provider name."""
|
||||
name = model_name(model)
|
||||
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
if name in ("jinav1", "jinav2"):
|
||||
return SemanticSearchModel(name)
|
||||
|
||||
return SemanticSearchModel.genai
|
||||
|
||||
|
||||
def transcription_model(model: object) -> TranscriptionModel | None:
|
||||
"""Whisper, or genai for any GenAI provider name."""
|
||||
name = model_name(model)
|
||||
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
return TranscriptionModel.whisper if name == "whisper" else TranscriptionModel.genai
|
||||
|
||||
|
||||
def users() -> dict[UserRole, int]:
|
||||
"""Count users by role, with any custom role counted as custom."""
|
||||
roles: Counter[UserRole] = Counter(
|
||||
closed(UserRole, user.role, UserRole.custom) for user in User.select(User.role)
|
||||
)
|
||||
return histogram(roles)
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> FeaturesSection:
|
||||
"""Report which enrichments and integrations are configured."""
|
||||
config = ctx.config
|
||||
devices = ctx.stats.get("embeddings", {}).get("devices", {})
|
||||
cameras = [
|
||||
camera
|
||||
for name, camera in config.cameras.items()
|
||||
if not name.startswith(REPLAY_CAMERA_PREFIX)
|
||||
]
|
||||
providers: Counter[GenAIProviderEnum] = Counter(
|
||||
genai.provider for genai in config.genai.values()
|
||||
)
|
||||
roles: Counter[GenAIRoleEnum] = Counter(
|
||||
role for genai in config.genai.values() for role in genai.roles
|
||||
)
|
||||
custom = list(config.classification.custom.values())
|
||||
|
||||
return FeaturesSection(
|
||||
face_recognition=EnrichmentUsage(
|
||||
enabled=config.face_recognition.enabled,
|
||||
model_size=config.face_recognition.model_size,
|
||||
device=enrichment_device(devices.get("face_recognition")),
|
||||
),
|
||||
lpr=EnrichmentUsage(
|
||||
enabled=config.lpr.enabled,
|
||||
model_size=config.lpr.model_size,
|
||||
device=enrichment_device(devices.get("lpr")),
|
||||
),
|
||||
semantic_search=SemanticSearchUsage(
|
||||
enabled=config.semantic_search.enabled,
|
||||
model=semantic_model(config.semantic_search.model),
|
||||
model_size=config.semantic_search.model_size,
|
||||
device=enrichment_device(devices.get("semantic_search")),
|
||||
triggers=sum(len(camera.semantic_search.triggers) for camera in cameras),
|
||||
),
|
||||
audio_transcription=TranscriptionUsage(
|
||||
enabled=config.audio_transcription.enabled,
|
||||
model=transcription_model(config.audio_transcription.model),
|
||||
model_size=config.audio_transcription.model_size,
|
||||
),
|
||||
genai=GenAIUsage(providers=histogram(providers), roles=histogram(roles)),
|
||||
classification_models=ClassificationUsage(
|
||||
state=sum(1 for model in custom if model.state_config is not None),
|
||||
object=sum(1 for model in custom if model.object_config is not None),
|
||||
),
|
||||
birdseye=BirdseyeUsage(
|
||||
enabled=config.birdseye.enabled,
|
||||
modes=list(config.birdseye.modes),
|
||||
restream=config.birdseye.restream,
|
||||
),
|
||||
mqtt=config.mqtt.enabled,
|
||||
notifications=config.notifications.enabled,
|
||||
auth=config.auth.enabled,
|
||||
proxy_auth=config.proxy.header_map.user is not None,
|
||||
tls=config.tls.enabled,
|
||||
users=users(),
|
||||
camera_groups=len(config.camera_groups),
|
||||
profiles=len(config.profiles),
|
||||
plus_api_key=config.plus_api.is_active(),
|
||||
)
|
||||
@@ -1,114 +0,0 @@
|
||||
"""Hardware section: CPU, memory, GPUs, decode and detection hardware, storage."""
|
||||
|
||||
import os
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
import psutil
|
||||
|
||||
from frigate.analytics.collectors.common import closed, histogram
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import (
|
||||
DecodeFamily,
|
||||
GpuInfo,
|
||||
GpuVendor,
|
||||
HardwareKey,
|
||||
HardwareSection,
|
||||
StorageInfo,
|
||||
)
|
||||
from frigate.const import RECORD_DIR
|
||||
from frigate.detectors.hardware import hardware_prober
|
||||
from frigate.util.hwaccel import hwaccel_options
|
||||
|
||||
DEVICE_TREE_MODEL = "/proc/device-tree/model"
|
||||
CPUINFO = "/proc/cpuinfo"
|
||||
|
||||
|
||||
def cpu_model() -> str:
|
||||
"""The board model on ARM boards, else the CPU's model name."""
|
||||
try:
|
||||
with open(DEVICE_TREE_MODEL) as f:
|
||||
board = f.read().strip("\x00\n ")
|
||||
|
||||
if board:
|
||||
return board[:64]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
with open(CPUINFO) as f:
|
||||
for line in f:
|
||||
key, _, value = line.partition(":")
|
||||
|
||||
if key.strip() == "model name" and value.strip():
|
||||
return value.strip()[:64]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def gpus(stats: dict[str, Any]) -> list[GpuInfo]:
|
||||
"""GPUs with a stats entry, by vendor and capped name."""
|
||||
found: list[GpuInfo] = []
|
||||
|
||||
for name, entry in stats.get("gpu_usages", {}).items():
|
||||
vendor = entry.get("vendor") if isinstance(entry, dict) else None
|
||||
found.append(
|
||||
GpuInfo(
|
||||
vendor=closed(GpuVendor, vendor, GpuVendor.other),
|
||||
name=str(name)[:64],
|
||||
)
|
||||
)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def decode_families() -> list[DecodeFamily]:
|
||||
"""Hardware decode families the host supports."""
|
||||
_, available = hwaccel_options()
|
||||
families = [
|
||||
closed(DecodeFamily, family.key, DecodeFamily.other) for family in available
|
||||
]
|
||||
return list(dict.fromkeys(families))
|
||||
|
||||
|
||||
def detection_hardware() -> dict[HardwareKey, int]:
|
||||
"""Detection accelerators found by the hardware probe, by unit count."""
|
||||
units: Counter[HardwareKey] = Counter()
|
||||
|
||||
for found in hardware_prober.probe():
|
||||
units[closed(HardwareKey, found.key, HardwareKey.other)] += found.count
|
||||
|
||||
return histogram(units)
|
||||
|
||||
|
||||
def storage(stats: dict[str, Any]) -> StorageInfo:
|
||||
"""Filesystem, size, and usage of the recordings volume.
|
||||
|
||||
Stats report sizes in MB.
|
||||
"""
|
||||
entry = stats.get("service", {}).get("storage", {}).get(RECORD_DIR) or {}
|
||||
total_mb = float(entry.get("total") or 0)
|
||||
used_mb = float(entry.get("used") or 0)
|
||||
|
||||
return StorageInfo(
|
||||
record_fs=str(entry.get("mount_type") or "unknown")[:16],
|
||||
record_total_gb=round(total_mb / 1024),
|
||||
record_used_pct=min(round(used_mb / total_mb * 100), 100)
|
||||
if total_mb > 0
|
||||
else 0,
|
||||
)
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> HardwareSection:
|
||||
"""Describe the host: CPU, memory, GPUs, decoders, detectors, storage."""
|
||||
return HardwareSection(
|
||||
cpu_model=cpu_model(),
|
||||
cpu_cores=os.cpu_count() or 0,
|
||||
memory_gb=round(psutil.virtual_memory().total / 2**30),
|
||||
gpus=gpus(ctx.stats),
|
||||
decode_families=decode_families(),
|
||||
detection_hardware=detection_hardware(),
|
||||
storage=storage(ctx.stats),
|
||||
)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Health section: uptime, CPU, enrichment speed, and notice counts."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from frigate.analytics.collectors.common import rate
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import (
|
||||
EnrichmentTiming,
|
||||
HealthSection,
|
||||
NoticeCounts,
|
||||
NoticeKindKey,
|
||||
)
|
||||
|
||||
TIMING_STATS = {
|
||||
EnrichmentTiming.face: "face_recognition_speed",
|
||||
EnrichmentTiming.lpr: "plate_recognition_speed",
|
||||
EnrichmentTiming.plate_detection: "yolov9_plate_detection_speed",
|
||||
EnrichmentTiming.image_embedding: "image_embedding_speed",
|
||||
EnrichmentTiming.text_embedding: "text_embedding_speed",
|
||||
EnrichmentTiming.review_description: "review_description_speed",
|
||||
EnrichmentTiming.object_description: "object_description_speed",
|
||||
}
|
||||
REPORTABLE_KINDS = frozenset(key.value for key in NoticeKindKey)
|
||||
|
||||
|
||||
def notice_counts(notice_stats: list[dict[str, Any]]) -> dict[Any, NoticeCounts]:
|
||||
"""Counts since the install ID was created, per reportable kind."""
|
||||
counts: dict[Any, NoticeCounts] = {}
|
||||
|
||||
for row in notice_stats:
|
||||
if row["kind"] not in REPORTABLE_KINDS:
|
||||
continue
|
||||
|
||||
occurrences = max(row["occurrences"] - row["reported_occurrences"], 0)
|
||||
acknowledgements = max(
|
||||
row["acknowledgements"] - row["reported_acknowledgements"], 0
|
||||
)
|
||||
mutes = max(row["mutes"] - row["reported_mutes"], 0)
|
||||
|
||||
if occurrences or acknowledgements or mutes:
|
||||
counts[NoticeKindKey(row["kind"])] = NoticeCounts(
|
||||
occurrences=occurrences, acknowledgements=acknowledgements, mutes=mutes
|
||||
)
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> HealthSection:
|
||||
"""Read health figures from the latest stats and notice counts."""
|
||||
service = ctx.stats.get("service", {})
|
||||
embeddings = ctx.stats.get("embeddings", {})
|
||||
cpu = ctx.stats.get("cpu_usages", {}).get("frigate.full_system", {}).get("cpu")
|
||||
timings = {
|
||||
timing: rate(embeddings.get(key)) for timing, key in TIMING_STATS.items()
|
||||
}
|
||||
|
||||
return HealthSection(
|
||||
uptime_hours=int(rate(service.get("uptime")) // 3600),
|
||||
cpu_percent=min(round(rate(cpu)), 100),
|
||||
enrichment_ms={timing: value for timing, value in timings.items() if value > 0},
|
||||
retention_unmet=bool(service.get("retention_unmet", False)),
|
||||
notices=notice_counts(ctx.notice_stats),
|
||||
)
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Install section: version, image variant, install type, platform."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
|
||||
from frigate.analytics.collectors.common import closed
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import Arch, ImageVariant, InstallSection, InstallType
|
||||
from frigate.version import VERSION
|
||||
|
||||
KERNEL_PATTERN = re.compile(r"^(\d{1,3})\.(\d{1,3})")
|
||||
|
||||
|
||||
def image_variant(value: str | None) -> ImageVariant:
|
||||
"""The published image from the build-time FRIGATE_IMAGE_VARIANT, dev when unset."""
|
||||
if not value:
|
||||
return ImageVariant.dev
|
||||
|
||||
return closed(ImageVariant, value, ImageVariant.other)
|
||||
|
||||
|
||||
def install_type() -> InstallType:
|
||||
"""How Frigate runs; the add-on is a container too, so it's checked first."""
|
||||
if os.path.isfile("/data/options.json"):
|
||||
return InstallType.ha_addon
|
||||
|
||||
if os.environ.get("KUBERNETES_SERVICE_HOST"):
|
||||
return InstallType.kubernetes
|
||||
|
||||
if os.path.exists("/run/.containerenv"):
|
||||
return InstallType.podman
|
||||
|
||||
if os.path.exists("/.dockerenv"):
|
||||
return InstallType.docker
|
||||
|
||||
return InstallType.unknown
|
||||
|
||||
|
||||
def arch(machine: str) -> Arch:
|
||||
"""The CPU architecture for a platform.machine() value."""
|
||||
match machine.lower():
|
||||
case "x86_64" | "amd64":
|
||||
return Arch.x86_64
|
||||
case "aarch64" | "arm64":
|
||||
return Arch.aarch64
|
||||
case _:
|
||||
return Arch.other
|
||||
|
||||
|
||||
def kernel(release: str) -> str:
|
||||
"""The major.minor of a kernel release string, or unknown."""
|
||||
match = KERNEL_PATTERN.match(release)
|
||||
return f"{match.group(1)}.{match.group(2)}" if match else "unknown"
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> InstallSection:
|
||||
"""Describe the Frigate version, image, install method, and platform."""
|
||||
return InstallSection(
|
||||
version=VERSION[:32],
|
||||
image_variant=image_variant(os.environ.get("FRIGATE_IMAGE_VARIANT")),
|
||||
install_type=install_type(),
|
||||
arch=arch(platform.machine()),
|
||||
kernel=kernel(platform.release()),
|
||||
run_as_root=os.geteuid() == 0,
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
"""What the collectors read, gathered once per report."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportContext:
|
||||
config: FrigateConfig
|
||||
stats: dict[str, Any]
|
||||
notice_stats: list[dict[str, Any]] = field(default_factory=list)
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Build a report from the section collectors."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from frigate.analytics.collectors import (
|
||||
cameras,
|
||||
detection,
|
||||
features,
|
||||
hardware,
|
||||
health,
|
||||
install,
|
||||
)
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport
|
||||
from frigate.analytics.state import load_state
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COLLECTORS: dict[str, Callable[[ReportContext], BaseModel | None]] = {
|
||||
"install": install.collect,
|
||||
"hardware": hardware.collect,
|
||||
"detection": detection.collect,
|
||||
"cameras": cameras.collect,
|
||||
"features": features.collect,
|
||||
"health": health.collect,
|
||||
}
|
||||
|
||||
# shown in the preview until the first report creates a real ID
|
||||
PREVIEW_INSTALL_ID = "0" * 32
|
||||
|
||||
|
||||
def build_report(
|
||||
ctx: ReportContext, install_id: str, sent_at: int | None = None
|
||||
) -> AnalyticsReport:
|
||||
"""Run every collector; one that fails sends its section as null."""
|
||||
sections: dict[str, Any] = {}
|
||||
|
||||
for name, collect in COLLECTORS.items():
|
||||
try:
|
||||
sections[name] = collect(ctx)
|
||||
except Exception:
|
||||
# a collector bug must cost one section, never the whole report
|
||||
logger.debug("Analytics %s section failed", name, exc_info=True)
|
||||
sections[name] = None
|
||||
|
||||
return AnalyticsReport.model_validate(
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"install_id": install_id,
|
||||
"report_id": str(uuid4()),
|
||||
"sent_at": int(time.time()) if sent_at is None else sent_at,
|
||||
**sections,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def gather_context(
|
||||
config: FrigateConfig,
|
||||
stats_emitter: "StatsEmitter | None",
|
||||
notice_registry: "NoticeRegistry | None",
|
||||
) -> ReportContext:
|
||||
"""Snapshot the config, latest stats, and notice counts for one report."""
|
||||
return ReportContext(
|
||||
config=config,
|
||||
stats=stats_emitter.get_latest_stats() if stats_emitter is not None else {},
|
||||
notice_stats=notice_registry.stats() if notice_registry is not None else [],
|
||||
)
|
||||
|
||||
|
||||
def preview_report(
|
||||
config: FrigateConfig,
|
||||
stats_emitter: "StatsEmitter | None",
|
||||
notice_registry: "NoticeRegistry | None",
|
||||
) -> AnalyticsReport:
|
||||
"""The report the next send would carry, without sending it."""
|
||||
state = load_state()
|
||||
|
||||
# an opt-in resets the baseline, so before one there are no notices to show
|
||||
ctx = gather_context(config, stats_emitter, notice_registry if state else None)
|
||||
return build_report(ctx, state.install_id if state else PREVIEW_INSTALL_ID)
|
||||
@@ -1,218 +0,0 @@
|
||||
"""Send one analytics report a day while the admin has opted in."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import ValidationError
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.error import YAMLError
|
||||
|
||||
from frigate.analytics.report import build_report, gather_context
|
||||
from frigate.analytics.state import (
|
||||
STATE_PATH,
|
||||
AnalyticsState,
|
||||
delete_state,
|
||||
load_state,
|
||||
new_state,
|
||||
save_state,
|
||||
)
|
||||
from frigate.analytics.transport import SendOutcome, send_report
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.holder import ConfigHolder
|
||||
from frigate.config.telemetry import TelemetryConfig
|
||||
from frigate.const import ANALYTICS_URL
|
||||
from frigate.notices import raise_notice, resolve_notice
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WAKE_S = 10 * 60
|
||||
INTERVAL_S = 24 * 60 * 60
|
||||
JITTER_S = 60 * 60
|
||||
FIRST_DELAY_S = (15 * 60, 45 * 60)
|
||||
PROMPT_KIND = "analytics_prompt"
|
||||
|
||||
|
||||
def saved_consent(path: str) -> bool | None:
|
||||
"""telemetry.analytics as saved in the config file, or None if unreadable."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
raw = YAML(typ="safe").load(f)
|
||||
except (OSError, ValueError, YAMLError):
|
||||
return None
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
# the live config's model, so yes, on, and 1 read as consent there too
|
||||
try:
|
||||
return TelemetryConfig.model_validate(raw.get("telemetry") or {}).analytics
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
class AnalyticsReporter(threading.Thread):
|
||||
"""Follows the live config, so a settings save needs no restart."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_holder: ConfigHolder,
|
||||
stats_emitter: "StatsEmitter",
|
||||
notice_registry: "NoticeRegistry",
|
||||
stop_event: MpEvent | threading.Event,
|
||||
*,
|
||||
state_path: str = STATE_PATH,
|
||||
config_path: str | None = None,
|
||||
url: str = ANALYTICS_URL,
|
||||
send: Callable[[str, str], SendOutcome] = send_report,
|
||||
clock: Callable[[], float] = time.time,
|
||||
rng: random.Random | None = None,
|
||||
) -> None:
|
||||
super().__init__(name="analytics_reporter", daemon=True)
|
||||
self.config_holder = config_holder
|
||||
self.stats_emitter = stats_emitter
|
||||
self.notice_registry = notice_registry
|
||||
self.stop_event = stop_event
|
||||
self.state_path = state_path
|
||||
self.config_path = config_path or find_config_file()
|
||||
self.url = url
|
||||
self.send = send
|
||||
self.clock = clock
|
||||
self.rng = rng or random.Random()
|
||||
self.first_due = clock() + self.rng.uniform(*FIRST_DELAY_S)
|
||||
self.interval = self._next_interval()
|
||||
self.opted_in: bool | None = None
|
||||
self.warned_unwritable = False
|
||||
# a settings save runs on an API thread while a wake may be mid-attempt
|
||||
self._lock = threading.Lock()
|
||||
config_holder.subscribe(self._on_config)
|
||||
|
||||
def _next_interval(self) -> float:
|
||||
return INTERVAL_S + self.rng.uniform(-JITTER_S, JITTER_S)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Tick every wake until stopped; a failed tick never ends the thread."""
|
||||
while True:
|
||||
try:
|
||||
self.tick()
|
||||
except Exception:
|
||||
logger.exception("Analytics reporter failed")
|
||||
|
||||
if self.stop_event.wait(WAKE_S):
|
||||
break
|
||||
|
||||
def _on_config(self, config: FrigateConfig) -> None:
|
||||
# a save can turn sharing off and back on between two wakes, so an
|
||||
# opt-out is handled when it's saved rather than at the next wake
|
||||
with self._lock:
|
||||
if not config.safe_mode:
|
||||
self._apply_consent(config.telemetry.analytics)
|
||||
|
||||
def _apply_consent(self, opted_in: bool) -> None:
|
||||
# called with the lock held
|
||||
if opted_in == self.opted_in:
|
||||
return
|
||||
|
||||
self.opted_in = opted_in
|
||||
|
||||
if opted_in:
|
||||
resolve_notice(PROMPT_KIND)
|
||||
|
||||
# the opt-in sets the notice baseline, so only notices raised with
|
||||
# consent are counted; tick() retries a failed write
|
||||
if load_state(self.state_path) is None:
|
||||
save_state(self._new_identity(), self.state_path)
|
||||
else:
|
||||
raise_notice(PROMPT_KIND)
|
||||
delete_state(self.state_path)
|
||||
|
||||
def _new_identity(self) -> AnalyticsState:
|
||||
# called with the lock held; the baseline resets before the state file
|
||||
# names the new ID, so a failed write only means another reset
|
||||
self.notice_registry.reset_analytics_baseline()
|
||||
return new_state()
|
||||
|
||||
def tick(self) -> None:
|
||||
"""Apply consent and send a report when one is due."""
|
||||
with self._lock:
|
||||
config = self.config_holder.config
|
||||
|
||||
# safe mode parses a default config where analytics reads as off,
|
||||
# and handling that as an opt-out would delete the install ID
|
||||
if config.safe_mode:
|
||||
return
|
||||
|
||||
self._apply_consent(config.telemetry.analytics)
|
||||
|
||||
if not config.telemetry.analytics:
|
||||
return
|
||||
|
||||
now = self.clock()
|
||||
|
||||
if now < self.first_due:
|
||||
return
|
||||
|
||||
state = load_state(self.state_path)
|
||||
|
||||
if state is not None and not self._due(state.last_attempt_at, now):
|
||||
return
|
||||
|
||||
# a YAML editor save or a hand edit reaches the live config only on
|
||||
# restart, so an opt-out saved that way is read from the file
|
||||
if saved_consent(self.config_path) is False:
|
||||
delete_state(self.state_path)
|
||||
return
|
||||
|
||||
state = AnalyticsState(
|
||||
(state or self._new_identity()).install_id, last_attempt_at=now
|
||||
)
|
||||
|
||||
# saved before sending, so a failing endpoint or a crash mid-send
|
||||
# still waits a full interval; without saved state every boot would
|
||||
# send under a new install ID
|
||||
if not save_state(state, self.state_path):
|
||||
if not self.warned_unwritable:
|
||||
logger.warning(
|
||||
"Analytics is on, but %s isn't writable, so no report is sent",
|
||||
self.state_path,
|
||||
)
|
||||
self.warned_unwritable = True
|
||||
|
||||
return
|
||||
|
||||
self.interval = self._next_interval()
|
||||
|
||||
# the lock stays free during the request, so a save never waits on it
|
||||
self._send(state.install_id, now)
|
||||
|
||||
def _due(self, last_attempt_at: float, now: float) -> bool:
|
||||
# a last attempt stamped in the future came from a wrong clock
|
||||
return (
|
||||
now >= last_attempt_at + self.interval or last_attempt_at > now + INTERVAL_S
|
||||
)
|
||||
|
||||
def _send(self, install_id: str, now: float) -> None:
|
||||
ctx = gather_context(
|
||||
self.config_holder.config, self.stats_emitter, self.notice_registry
|
||||
)
|
||||
report = build_report(ctx, install_id, sent_at=int(now))
|
||||
body = report.model_dump_json()
|
||||
|
||||
# consent can be withdrawn while the report builds
|
||||
if not self.config_holder.config.telemetry.analytics:
|
||||
return
|
||||
|
||||
if self.send(self.url, body) is not SendOutcome.accepted:
|
||||
return
|
||||
|
||||
logger.info("Sent the daily analytics report")
|
||||
logger.debug("Analytics report: %s", body)
|
||||
@@ -1,419 +0,0 @@
|
||||
"""Models for the analytics report, the contract with the ingest endpoint.
|
||||
|
||||
Every field carries a description and an x-public flag, and no field accepts
|
||||
user-entered text, so a report can't carry camera names or other free text.
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from frigate.config.camera.birdseye import BirdseyeModeEnum
|
||||
from frigate.config.camera.camera import CameraTypeEnum
|
||||
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
|
||||
from frigate.config.classification import ModelSizeEnum
|
||||
from frigate.detectors.detector_config import ModelTypeEnum
|
||||
from frigate.detectors.detector_types import DetectorTypeEnum
|
||||
from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT
|
||||
from frigate.notices.types import NOTICE_KINDS
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class ImageVariant(StrEnum):
|
||||
standard = "standard"
|
||||
rpi = "rpi"
|
||||
tensorrt = "tensorrt"
|
||||
tensorrt_jp6 = "tensorrt-jp6"
|
||||
rocm = "rocm"
|
||||
rk = "rk"
|
||||
synaptics = "synaptics"
|
||||
dev = "dev"
|
||||
other = "other"
|
||||
|
||||
|
||||
class InstallType(StrEnum):
|
||||
ha_addon = "ha_addon"
|
||||
docker = "docker"
|
||||
podman = "podman"
|
||||
kubernetes = "kubernetes"
|
||||
unknown = "unknown"
|
||||
|
||||
|
||||
class Arch(StrEnum):
|
||||
x86_64 = "x86_64"
|
||||
aarch64 = "aarch64"
|
||||
other = "other"
|
||||
|
||||
|
||||
class GpuVendor(StrEnum):
|
||||
intel = "intel"
|
||||
amd = "amd"
|
||||
nvidia = "nvidia"
|
||||
rockchip = "rockchip"
|
||||
rpi = "rpi"
|
||||
apple_silicon = "apple-silicon"
|
||||
other = "other"
|
||||
|
||||
|
||||
class DecodeFamily(StrEnum):
|
||||
nvidia = "nvidia"
|
||||
vaapi = "vaapi"
|
||||
rkmpp = "rkmpp"
|
||||
intel_qsv = "intel-qsv"
|
||||
jetson = "jetson"
|
||||
rpi = "rpi"
|
||||
apple_silicon = "apple-silicon"
|
||||
other = "other"
|
||||
|
||||
|
||||
class HardwareKey(StrEnum):
|
||||
edgetpu_pci = "edgetpu:pci"
|
||||
edgetpu_usb = "edgetpu:usb"
|
||||
openvino_gpu = "openvino:GPU"
|
||||
openvino_npu = "openvino:NPU"
|
||||
onnx_amd = "onnx:amd"
|
||||
onnx_nvidia = "onnx:nvidia"
|
||||
onnx_lighter = "onnx:lighter"
|
||||
tensorrt = "tensorrt"
|
||||
hailo = "hailo"
|
||||
memryx = "memryx"
|
||||
deepx = "deepx"
|
||||
rknn = "rknn"
|
||||
axengine = "axengine"
|
||||
axelera = "axelera"
|
||||
synaptics = "synaptics"
|
||||
cpu = "cpu"
|
||||
other = "other"
|
||||
|
||||
|
||||
class ModelSource(StrEnum):
|
||||
default = "default"
|
||||
plus = "plus"
|
||||
custom = "custom"
|
||||
|
||||
|
||||
class HeightBucket(StrEnum):
|
||||
le_360 = "le_360"
|
||||
h480 = "480"
|
||||
h720 = "720"
|
||||
h1080 = "1080"
|
||||
h1440 = "1440"
|
||||
ge_2160 = "ge_2160"
|
||||
|
||||
|
||||
class FpsBucket(StrEnum):
|
||||
le_5 = "le_5"
|
||||
f6_10 = "6_10"
|
||||
gt_10 = "gt_10"
|
||||
|
||||
|
||||
class RetainBucket(StrEnum):
|
||||
zero = "0"
|
||||
d1_7 = "1_7"
|
||||
d8_30 = "8_30"
|
||||
gt_30 = "gt_30"
|
||||
|
||||
|
||||
class ConnectionQuality(StrEnum):
|
||||
excellent = "excellent"
|
||||
fair = "fair"
|
||||
poor = "poor"
|
||||
unusable = "unusable"
|
||||
|
||||
|
||||
class EnrichmentDevice(StrEnum):
|
||||
cpu = "cpu"
|
||||
cuda = "cuda"
|
||||
tensorrt = "tensorrt"
|
||||
migraphx = "migraphx"
|
||||
openvino_cpu = "openvino_cpu"
|
||||
openvino_gpu = "openvino_gpu"
|
||||
openvino_npu = "openvino_npu"
|
||||
other = "other"
|
||||
|
||||
|
||||
class SemanticSearchModel(StrEnum):
|
||||
jinav1 = "jinav1"
|
||||
jinav2 = "jinav2"
|
||||
genai = "genai"
|
||||
|
||||
|
||||
class TranscriptionModel(StrEnum):
|
||||
whisper = "whisper"
|
||||
genai = "genai"
|
||||
|
||||
|
||||
class UserRole(StrEnum):
|
||||
admin = "admin"
|
||||
viewer = "viewer"
|
||||
custom = "custom"
|
||||
|
||||
|
||||
class EnrichmentTiming(StrEnum):
|
||||
face = "face"
|
||||
lpr = "lpr"
|
||||
plate_detection = "plate_detection"
|
||||
image_embedding = "image_embedding"
|
||||
text_embedding = "text_embedding"
|
||||
review_description = "review_description"
|
||||
object_description = "object_description"
|
||||
|
||||
|
||||
def _preset_keys(presets: dict[str, Any]) -> dict[str, str]:
|
||||
keys = [name.removeprefix("preset-") for name in presets]
|
||||
return {key: key for key in [*keys, "custom", "none"]}
|
||||
|
||||
|
||||
# built from the registries they mirror, so a new preset or notice kind reaches
|
||||
# the schema through generate_analytics_schema.py instead of a hand edit
|
||||
HwaccelKey = StrEnum("HwaccelKey", _preset_keys(PRESETS_HW_ACCEL_DECODE)) # type: ignore[misc]
|
||||
InputPresetKey = StrEnum("InputPresetKey", _preset_keys(PRESETS_INPUT)) # type: ignore[misc]
|
||||
NoticeKindKey = StrEnum( # type: ignore[misc]
|
||||
"NoticeKindKey",
|
||||
{key: key for key, kind in NOTICE_KINDS.items() if kind.reportable},
|
||||
)
|
||||
|
||||
|
||||
class AnalyticsModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def metric(description: str, *, public: bool = True, **kwargs: Any) -> Any:
|
||||
"""Declare a report field; the description and flag land in the schema."""
|
||||
return Field(
|
||||
description=description, json_schema_extra={"x-public": public}, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def count(description: str) -> Any:
|
||||
"""Declare a non-negative count field."""
|
||||
return metric(description, ge=0)
|
||||
|
||||
|
||||
class InstallSection(AnalyticsModel):
|
||||
version: str = metric("Frigate version string", max_length=32)
|
||||
image_variant: ImageVariant = metric("Published image the install runs")
|
||||
install_type: InstallType = metric("How Frigate is installed")
|
||||
arch: Arch = metric("CPU architecture")
|
||||
kernel: str = metric(
|
||||
"Host kernel as major.minor", pattern=r"^(\d{1,3}\.\d{1,3}|unknown)$"
|
||||
)
|
||||
run_as_root: bool = metric("Whether the main process runs as root")
|
||||
|
||||
|
||||
class GpuInfo(AnalyticsModel):
|
||||
vendor: GpuVendor = metric("GPU vendor")
|
||||
name: str = metric("GPU name as the hardware reports it", max_length=64)
|
||||
|
||||
|
||||
class StorageInfo(AnalyticsModel):
|
||||
record_fs: str = metric("Filesystem of the recordings volume", max_length=16)
|
||||
record_total_gb: int = count("Size of the recordings volume in GB")
|
||||
record_used_pct: int = metric(
|
||||
"Percent of the recordings volume in use", ge=0, le=100
|
||||
)
|
||||
|
||||
|
||||
class HardwareSection(AnalyticsModel):
|
||||
cpu_model: str = metric(
|
||||
"CPU or board model as the hardware reports it", max_length=64
|
||||
)
|
||||
cpu_cores: int = count("Logical CPU count")
|
||||
memory_gb: int = count("Total memory in GB")
|
||||
gpus: list[GpuInfo] = metric("GPUs the stats collector found")
|
||||
decode_families: list[DecodeFamily] = metric(
|
||||
"Hardware decode families this system can use"
|
||||
)
|
||||
detection_hardware: dict[HardwareKey, int] = metric(
|
||||
"Detection hardware found, as unit counts by kind"
|
||||
)
|
||||
storage: StorageInfo = metric("Recordings storage")
|
||||
|
||||
|
||||
class DetectionModel(AnalyticsModel):
|
||||
default: bool = metric("Whether the model serves cameras that set no scene")
|
||||
detector: DetectorTypeEnum = metric("Detector type the model runs on")
|
||||
devices: int = count("Devices the model runs on")
|
||||
model_type: ModelTypeEnum = metric("Model architecture")
|
||||
input: str = metric("Model input size as WxH", pattern=r"^\d{1,5}x\d{1,5}$")
|
||||
source: ModelSource = metric("Where the model came from", public=False)
|
||||
inference_ms: float | None = metric(
|
||||
"Mean inference time across the model's detector processes, null before the first stats",
|
||||
ge=0,
|
||||
)
|
||||
|
||||
|
||||
class DetectionSection(AnalyticsModel):
|
||||
models: list[DetectionModel] = metric("Detection models")
|
||||
detection_fps: float = metric("Detections per second across cameras", ge=0)
|
||||
skipped_fps: float = metric("Frames per second skipped across cameras", ge=0)
|
||||
|
||||
|
||||
class RetainDays(AnalyticsModel):
|
||||
continuous: dict[RetainBucket, int] = metric(
|
||||
"Recording cameras by continuous retention in days"
|
||||
)
|
||||
motion: dict[RetainBucket, int] = metric(
|
||||
"Recording cameras by motion retention in days"
|
||||
)
|
||||
alerts: dict[RetainBucket, int] = metric(
|
||||
"Recording cameras by alert retention in days"
|
||||
)
|
||||
detections: dict[RetainBucket, int] = metric(
|
||||
"Recording cameras by detection retention in days"
|
||||
)
|
||||
|
||||
|
||||
class CamerasSection(AnalyticsModel):
|
||||
total: int = count("Configured cameras")
|
||||
enabled: int = count(
|
||||
"Cameras enabled in config; every count below covers only these"
|
||||
)
|
||||
types: dict[CameraTypeEnum, int] = metric("Cameras by type")
|
||||
detect_height: dict[HeightBucket, int] = metric(
|
||||
"Cameras by detect resolution height"
|
||||
)
|
||||
detect_fps: dict[FpsBucket, int] = metric("Cameras by detect fps")
|
||||
hwaccel: dict[HwaccelKey, int] = metric(
|
||||
"Cameras by the resolved hwaccel preset of the detect input"
|
||||
)
|
||||
input_preset: dict[InputPresetKey, int] = metric(
|
||||
"Cameras by the input preset of the detect input"
|
||||
)
|
||||
go2rtc_restream: int = count("Cameras with an input from the go2rtc restream")
|
||||
separate_detect_stream: int = count(
|
||||
"Cameras whose detect input differs from their record input"
|
||||
)
|
||||
detect: int = count("Cameras with detection on")
|
||||
record: int = count("Cameras with recording on")
|
||||
sub_stream_record: int = count("Cameras with sub stream recording on")
|
||||
snapshots: int = count("Cameras with snapshots on")
|
||||
audio: int = count("Cameras with audio detection on")
|
||||
audio_transcription: int = count(
|
||||
"Cameras with audio detection and transcription on"
|
||||
)
|
||||
birdseye: int = count("Cameras in birdseye")
|
||||
onvif: int = count("Cameras with an ONVIF host")
|
||||
autotracking: int = count("Cameras with PTZ autotracking on")
|
||||
face_recognition: int = count("Cameras with face recognition on")
|
||||
lpr: int = count("Cameras with license plate recognition on")
|
||||
review_genai: int = count("Cameras with GenAI review summaries on")
|
||||
object_genai: int = count("Cameras with GenAI object descriptions on")
|
||||
notifications: int = count("Cameras with notifications on")
|
||||
zones: int = count("Zones across cameras")
|
||||
cameras_with_zones: int = count("Cameras with at least one zone")
|
||||
motion_masks: int = count("Motion masks across cameras")
|
||||
object_masks: int = count("Object masks across cameras")
|
||||
connection_quality: dict[ConnectionQuality, int] = metric(
|
||||
"Cameras by connection quality at send time"
|
||||
)
|
||||
retain_days: RetainDays = metric("Recording retention")
|
||||
|
||||
|
||||
class EnrichmentUsage(AnalyticsModel):
|
||||
enabled: bool = metric("Whether the enrichment is on")
|
||||
model_size: ModelSizeEnum = metric("Configured model size")
|
||||
device: EnrichmentDevice | None = metric(
|
||||
"Device the model loaded on, null when it isn't loaded"
|
||||
)
|
||||
|
||||
|
||||
class SemanticSearchUsage(AnalyticsModel):
|
||||
enabled: bool = metric("Whether semantic search is on")
|
||||
model: SemanticSearchModel | None = metric(
|
||||
"Embedding model, genai for a GenAI provider"
|
||||
)
|
||||
model_size: ModelSizeEnum = metric("Configured model size")
|
||||
device: EnrichmentDevice | None = metric(
|
||||
"Device the model loaded on, null when it isn't loaded"
|
||||
)
|
||||
triggers: int = count("Semantic search triggers across cameras")
|
||||
|
||||
|
||||
class TranscriptionUsage(AnalyticsModel):
|
||||
enabled: bool = metric("Whether audio transcription is on")
|
||||
model: TranscriptionModel | None = metric(
|
||||
"Transcription model, genai for a GenAI provider"
|
||||
)
|
||||
model_size: ModelSizeEnum = metric("Configured model size")
|
||||
|
||||
|
||||
class GenAIUsage(AnalyticsModel):
|
||||
providers: dict[GenAIProviderEnum, int] = metric(
|
||||
"Configured GenAI providers by type"
|
||||
)
|
||||
roles: dict[GenAIRoleEnum, int] = metric("Configured GenAI providers by role")
|
||||
|
||||
|
||||
class ClassificationUsage(AnalyticsModel):
|
||||
state: int = count("Custom state classification models")
|
||||
object: int = count("Custom object classification models")
|
||||
|
||||
|
||||
class BirdseyeUsage(AnalyticsModel):
|
||||
enabled: bool = metric("Whether birdseye is on")
|
||||
modes: list[BirdseyeModeEnum] = metric("Birdseye modes")
|
||||
restream: bool = metric("Whether birdseye is restreamed")
|
||||
|
||||
|
||||
class FeaturesSection(AnalyticsModel):
|
||||
face_recognition: EnrichmentUsage = metric("Face recognition")
|
||||
lpr: EnrichmentUsage = metric("License plate recognition")
|
||||
semantic_search: SemanticSearchUsage = metric("Semantic search")
|
||||
audio_transcription: TranscriptionUsage = metric("Audio transcription")
|
||||
genai: GenAIUsage = metric("Generative AI providers")
|
||||
classification_models: ClassificationUsage = metric("Custom classification models")
|
||||
birdseye: BirdseyeUsage = metric("Birdseye")
|
||||
mqtt: bool = metric("Whether MQTT is on")
|
||||
notifications: bool = metric("Whether web push notifications are on")
|
||||
auth: bool = metric("Whether authentication is on")
|
||||
proxy_auth: bool = metric("Whether a proxy supplies the user header")
|
||||
tls: bool = metric("Whether TLS is on")
|
||||
users: dict[UserRole, int] = metric("Users by role")
|
||||
camera_groups: int = count("Camera groups")
|
||||
profiles: int = count("Profiles")
|
||||
plus_api_key: bool = metric("Whether a Frigate+ API key is set", public=False)
|
||||
|
||||
|
||||
class NoticeCounts(AnalyticsModel):
|
||||
occurrences: int = count("Occurrences since the install ID was created")
|
||||
acknowledgements: int = count("Acknowledgements since the install ID was created")
|
||||
mutes: int = count("Mutes since the install ID was created")
|
||||
|
||||
|
||||
class HealthSection(AnalyticsModel):
|
||||
uptime_hours: int = count("Hours since Frigate started")
|
||||
cpu_percent: int = metric("System CPU use at send time", ge=0, le=100)
|
||||
enrichment_ms: dict[EnrichmentTiming, float] = metric(
|
||||
"Mean enrichment inference times in milliseconds"
|
||||
)
|
||||
retention_unmet: bool = metric(
|
||||
"Whether storage can't keep the configured retention"
|
||||
)
|
||||
notices: dict[NoticeKindKey, NoticeCounts] = metric(
|
||||
"Cumulative notice counts by kind since the install ID was created",
|
||||
public=False,
|
||||
)
|
||||
|
||||
|
||||
class AnalyticsReport(AnalyticsModel):
|
||||
schema_version: int = metric("Report format version", ge=1)
|
||||
install_id: str = metric(
|
||||
"Random install identifier", public=False, pattern=r"^[0-9a-f]{32}$"
|
||||
)
|
||||
report_id: str = metric(
|
||||
"Random identifier of this report",
|
||||
public=False,
|
||||
pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
)
|
||||
sent_at: int = count("Unix time the report was built")
|
||||
install: InstallSection | None = metric("Install, null if its collector failed")
|
||||
hardware: HardwareSection | None = metric("Hardware, null if its collector failed")
|
||||
detection: DetectionSection | None = metric(
|
||||
"Object detection, null if its collector failed"
|
||||
)
|
||||
cameras: CamerasSection | None = metric("Cameras, null if its collector failed")
|
||||
features: FeaturesSection | None = metric("Features, null if its collector failed")
|
||||
health: HealthSection | None = metric("Health, null if its collector failed")
|
||||
@@ -1,86 +0,0 @@
|
||||
"""The install's analytics identity and last attempt time, kept under /config."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4
|
||||
|
||||
from frigate.const import CONFIG_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_PATH = os.path.join(CONFIG_DIR, ".analytics.json")
|
||||
INSTALL_ID_PATTERN = re.compile(r"[0-9a-f]{32}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnalyticsState:
|
||||
install_id: str
|
||||
last_attempt_at: float
|
||||
|
||||
|
||||
def new_state() -> AnalyticsState:
|
||||
"""A fresh install ID that has never attempted a report."""
|
||||
return AnalyticsState(install_id=uuid4().hex, last_attempt_at=0.0)
|
||||
|
||||
|
||||
def load_state(path: str = STATE_PATH) -> AnalyticsState | None:
|
||||
"""The saved state, or None when the file is missing or unusable."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, ValueError):
|
||||
logger.debug("Ignoring unreadable analytics state at %s", path)
|
||||
return None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
install_id = data.get("install_id")
|
||||
last_attempt_at = data.get("last_attempt_at")
|
||||
|
||||
if (
|
||||
not isinstance(install_id, str)
|
||||
or not INSTALL_ID_PATTERN.fullmatch(install_id)
|
||||
or isinstance(last_attempt_at, bool)
|
||||
or not isinstance(last_attempt_at, int | float)
|
||||
):
|
||||
logger.debug("Ignoring invalid analytics state at %s", path)
|
||||
return None
|
||||
|
||||
return AnalyticsState(install_id=install_id, last_attempt_at=float(last_attempt_at))
|
||||
|
||||
|
||||
def save_state(state: AnalyticsState, path: str = STATE_PATH) -> bool:
|
||||
"""Write the state atomically, returning False when it can't be written."""
|
||||
temp_path = f"{path}.tmp"
|
||||
|
||||
try:
|
||||
with open(temp_path, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"install_id": state.install_id,
|
||||
"last_attempt_at": state.last_attempt_at,
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
os.replace(temp_path, path)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def delete_state(path: str = STATE_PATH) -> None:
|
||||
"""Forget the install ID, so a later opt-in starts a fresh identity."""
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
logger.warning("Unable to delete analytics state at %s", path)
|
||||
@@ -1,54 +0,0 @@
|
||||
"""POST a report to the ingest endpoint."""
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
|
||||
import requests
|
||||
|
||||
from frigate.version import VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT_S = 30
|
||||
|
||||
|
||||
class SendOutcome(Enum):
|
||||
accepted = "accepted"
|
||||
rejected = "rejected"
|
||||
rate_limited = "rate_limited"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
def send_report(url: str, body: str) -> SendOutcome:
|
||||
"""Send one report. Never raises; the outcome says what happened."""
|
||||
try:
|
||||
# a redirect would turn the POST into a GET, so it counts as a failure
|
||||
response = requests.post(
|
||||
url,
|
||||
data=body.encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"Frigate/{VERSION}",
|
||||
},
|
||||
timeout=TIMEOUT_S,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException as err:
|
||||
logger.debug("Unable to send the analytics report: %s", err)
|
||||
return SendOutcome.failed
|
||||
|
||||
status = response.status_code
|
||||
|
||||
if 200 <= status < 300:
|
||||
return SendOutcome.accepted
|
||||
|
||||
if status == 400:
|
||||
logger.debug("The analytics report was rejected: %s", response.text[:200])
|
||||
return SendOutcome.rejected
|
||||
|
||||
if status == 429:
|
||||
logger.debug("The analytics endpoint is rate limiting this install")
|
||||
return SendOutcome.rate_limited
|
||||
|
||||
logger.debug("The analytics endpoint returned %s", status)
|
||||
return SendOutcome.failed
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Analytics APIs."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from frigate.analytics.report import preview_report
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.analytics])
|
||||
|
||||
|
||||
@router.get("/analytics/preview", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_analytics_preview(request: Request) -> JSONResponse:
|
||||
"""Get the analytics report Frigate would send next, without sending it."""
|
||||
report = preview_report(
|
||||
request.app.frigate_config,
|
||||
request.app.stats_emitter,
|
||||
request.app.notice_registry,
|
||||
)
|
||||
return JSONResponse(content=report.model_dump(mode="json"))
|
||||
+14
-18
@@ -81,7 +81,6 @@ from frigate.util.object_names import get_categorized_object_names
|
||||
from frigate.util.schema import get_config_schema
|
||||
from frigate.util.services import (
|
||||
Go2rtcStreamIssue,
|
||||
find_restricted_go2rtc_exec_changes,
|
||||
find_restricted_go2rtc_stream_changes,
|
||||
get_nvidia_driver_info,
|
||||
process_logs,
|
||||
@@ -577,26 +576,26 @@ def config_raw():
|
||||
)
|
||||
|
||||
|
||||
def _go2rtc_config(raw_config: str) -> dict[str, Any]:
|
||||
"""Load the go2rtc section with the same loader Frigate and go2rtc startup use.
|
||||
def _go2rtc_streams(raw_config: str) -> Any:
|
||||
"""Load go2rtc.streams with the same loader Frigate and go2rtc startup use.
|
||||
|
||||
Raises ruamel.yaml.YAMLError when the config cannot be parsed.
|
||||
"""
|
||||
loaded = ruamel.yaml.YAML().load(raw_config)
|
||||
go2rtc = loaded.get("go2rtc") if isinstance(loaded, dict) else None
|
||||
return go2rtc if isinstance(go2rtc, dict) else {}
|
||||
return go2rtc.get("streams") if isinstance(go2rtc, dict) else None
|
||||
|
||||
|
||||
def _restricted_go2rtc_response(
|
||||
def _restricted_go2rtc_streams_response(
|
||||
old_raw_config: str, new_raw_config: str
|
||||
) -> JSONResponse | None:
|
||||
"""Reject config changes that add restricted go2rtc sources or options.
|
||||
"""Reject config changes that add restricted go2rtc stream sources.
|
||||
|
||||
The same sources are rejected by PUT /go2rtc/streams, and go2rtc would
|
||||
otherwise load them from the saved config on the next restart.
|
||||
"""
|
||||
try:
|
||||
new_go2rtc = _go2rtc_config(new_raw_config)
|
||||
new_streams = _go2rtc_streams(new_raw_config)
|
||||
except ruamel.yaml.YAMLError:
|
||||
logger.warning("Rejected config change that could not be parsed")
|
||||
return JSONResponse(
|
||||
@@ -605,21 +604,16 @@ def _restricted_go2rtc_response(
|
||||
)
|
||||
|
||||
try:
|
||||
old_go2rtc = _go2rtc_config(old_raw_config)
|
||||
old_streams = _go2rtc_streams(old_raw_config)
|
||||
except ruamel.yaml.YAMLError:
|
||||
# with no readable saved config, every source is treated as new
|
||||
old_go2rtc = {}
|
||||
old_streams = None
|
||||
|
||||
issues = find_restricted_go2rtc_stream_changes(
|
||||
old_go2rtc.get("streams"), new_go2rtc.get("streams")
|
||||
)
|
||||
exec_keys = find_restricted_go2rtc_exec_changes(old_go2rtc, new_go2rtc)
|
||||
if not issues and not exec_keys:
|
||||
issues = find_restricted_go2rtc_stream_changes(old_streams, new_streams)
|
||||
if not issues:
|
||||
return None
|
||||
|
||||
messages = []
|
||||
if exec_keys:
|
||||
messages.append(f"Restricted go2rtc option: {', '.join(exec_keys)}")
|
||||
for issue, label in (
|
||||
(Go2rtcStreamIssue.restricted, "Restricted go2rtc stream source in"),
|
||||
(Go2rtcStreamIssue.unknown_variable, "Unknown variable in go2rtc stream"),
|
||||
@@ -723,7 +717,9 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
except OSError:
|
||||
old_raw_config = ""
|
||||
|
||||
restricted_response = _restricted_go2rtc_response(old_raw_config, new_config)
|
||||
restricted_response = _restricted_go2rtc_streams_response(
|
||||
old_raw_config, new_config
|
||||
)
|
||||
if restricted_response is not None:
|
||||
return restricted_response
|
||||
|
||||
@@ -1176,7 +1172,7 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
restricted_response = _restricted_go2rtc_response(
|
||||
restricted_response = _restricted_go2rtc_streams_response(
|
||||
old_raw_config, new_raw_config
|
||||
)
|
||||
if restricted_response is not None:
|
||||
|
||||
+8
-19
@@ -1,6 +1,5 @@
|
||||
"""Chat and LLM tool calling APIs."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
@@ -56,7 +55,6 @@ from frigate.genai.utils import (
|
||||
)
|
||||
from frigate.jobs.export import ExportQueueFullError, start_export_job
|
||||
from frigate.jobs.vlm_watch import (
|
||||
VLMWatchJob,
|
||||
get_vlm_watch_job,
|
||||
start_vlm_watch_job,
|
||||
stop_vlm_watch_job,
|
||||
@@ -1000,7 +998,7 @@ async def _execute_tool_internal(
|
||||
elif tool_name == "start_camera_watch":
|
||||
return await _execute_start_camera_watch(request, arguments)
|
||||
elif tool_name == "stop_camera_watch":
|
||||
return _execute_stop_camera_watch(request)
|
||||
return _execute_stop_camera_watch()
|
||||
elif tool_name == "get_profile_status":
|
||||
return _execute_get_profile_status(request)
|
||||
elif tool_name == "get_recap":
|
||||
@@ -1061,7 +1059,6 @@ async def _execute_start_camera_watch(
|
||||
dispatcher=request.app.dispatcher,
|
||||
labels=labels,
|
||||
zones=zones,
|
||||
username=request.headers.get("remote-user", ""),
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.exception("Failed to start VLM watch job: %s", e)
|
||||
@@ -1077,18 +1074,7 @@ async def _execute_start_camera_watch(
|
||||
}
|
||||
|
||||
|
||||
def _can_cancel_watch_job(request: Request, job: VLMWatchJob) -> bool:
|
||||
"""Admins can cancel any watch job; other users only jobs they started."""
|
||||
role = request.headers.get("remote-role", "viewer")
|
||||
username = request.headers.get("remote-user", "")
|
||||
return role == "admin" or username == job.username
|
||||
|
||||
|
||||
def _execute_stop_camera_watch(request: Request) -> dict[str, Any]:
|
||||
job = get_vlm_watch_job()
|
||||
if job is not None and not _can_cancel_watch_job(request, job):
|
||||
return {"success": False, "message": "Not authorized to cancel this watch job."}
|
||||
|
||||
def _execute_stop_camera_watch() -> dict[str, Any]:
|
||||
cancelled = stop_vlm_watch_job()
|
||||
if cancelled:
|
||||
return {"success": True, "message": "Watch job cancelled."}
|
||||
@@ -1667,8 +1653,7 @@ async def chat_completion(
|
||||
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s) in conversation"
|
||||
)
|
||||
response = await asyncio.to_thread(
|
||||
genai_client.chat_with_tools,
|
||||
response = genai_client.chat_with_tools(
|
||||
messages=conversation,
|
||||
tools=llm_tools if llm_tools else None,
|
||||
tool_choice="auto",
|
||||
@@ -1921,7 +1906,11 @@ async def cancel_vlm_monitor(request: Request) -> JSONResponse:
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if not _can_cancel_watch_job(request, job):
|
||||
role = request.headers.get("remote-role", "viewer")
|
||||
username = request.headers.get("remote-user", "")
|
||||
|
||||
# Admin can cancel any job; other users can only cancel jobs they started.
|
||||
if role != "admin" and username != job.username:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
|
||||
@@ -2,7 +2,6 @@ from enum import Enum
|
||||
|
||||
|
||||
class Tags(Enum):
|
||||
analytics = "Analytics"
|
||||
app = "App"
|
||||
auth = "Auth"
|
||||
camera = "Camera"
|
||||
|
||||
@@ -13,8 +13,8 @@ from slowapi.middleware import SlowAPIMiddleware
|
||||
from starlette_context import middleware, plugins
|
||||
from starlette_context.plugins import Plugin
|
||||
|
||||
from frigate.api import app as main_app
|
||||
from frigate.api import (
|
||||
analytics,
|
||||
auth,
|
||||
camera,
|
||||
chat,
|
||||
@@ -31,7 +31,6 @@ from frigate.api import (
|
||||
record,
|
||||
review,
|
||||
)
|
||||
from frigate.api import app as main_app
|
||||
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.comms.event_metadata_updater import (
|
||||
@@ -144,7 +143,6 @@ def create_fastapi_app(
|
||||
|
||||
# Routes
|
||||
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
|
||||
app.include_router(analytics.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(camera.router)
|
||||
app.include_router(chat.router)
|
||||
|
||||
@@ -15,7 +15,6 @@ import uvicorn
|
||||
from peewee import SqliteDatabase
|
||||
from peewee_migrate import Router
|
||||
|
||||
from frigate.analytics.reporter import AnalyticsReporter
|
||||
from frigate.api.auth import hash_password
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.camera import CameraMetrics, PTZMetrics
|
||||
@@ -525,15 +524,6 @@ class FrigateApp:
|
||||
)
|
||||
self.stats_emitter.start()
|
||||
|
||||
def start_analytics_reporter(self) -> None:
|
||||
self.analytics_reporter = AnalyticsReporter(
|
||||
self.config_holder,
|
||||
self.stats_emitter,
|
||||
self.notice_registry,
|
||||
self.stop_event,
|
||||
)
|
||||
self.analytics_reporter.start()
|
||||
|
||||
def start_watchdog(self) -> None:
|
||||
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
|
||||
|
||||
@@ -707,7 +697,6 @@ class FrigateApp:
|
||||
self.start_audio_processor()
|
||||
self.start_storage_maintainer()
|
||||
self.start_stats_emitter()
|
||||
self.start_analytics_reporter()
|
||||
self.start_timeline_processor()
|
||||
self.start_event_processor()
|
||||
self.start_event_cleanup()
|
||||
@@ -809,8 +798,6 @@ class FrigateApp:
|
||||
self.event_cleanup.join()
|
||||
self.record_cleanup.join()
|
||||
self.stats_emitter.join()
|
||||
# a send in flight can hold the thread for the whole request timeout
|
||||
self.analytics_reporter.join(timeout=5)
|
||||
self.frigate_watchdog.join()
|
||||
self.camera_maintainer.join()
|
||||
self.db.stop()
|
||||
|
||||
@@ -57,6 +57,11 @@ def birdseye_modes_to_mqtt_payload(modes: list[BirdseyeModeEnum]) -> str:
|
||||
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):
|
||||
scaling_factor: float = Field(
|
||||
default=2.0,
|
||||
@@ -79,7 +84,7 @@ class BirdseyeConfig(FrigateBaseModel):
|
||||
description="Enable or disable the Birdseye view feature.",
|
||||
)
|
||||
modes: list[BirdseyeModeEnum] = Field(
|
||||
default=[BirdseyeModeEnum.all_objects],
|
||||
default_factory=default_birdseye_modes,
|
||||
title="Activity types",
|
||||
description="Activity types that include cameras in Birdseye.",
|
||||
)
|
||||
@@ -134,7 +139,7 @@ class BirdseyeCameraConfig(BaseModel):
|
||||
description="Enable or disable the Birdseye view feature.",
|
||||
)
|
||||
modes: list[BirdseyeModeEnum] = Field(
|
||||
default=[BirdseyeModeEnum.all_objects],
|
||||
default_factory=default_birdseye_modes,
|
||||
title="Activity types",
|
||||
description="Activity types that include cameras in Birdseye.",
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ from enum import Enum
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from frigate.util.config import is_valid_ffmpeg_path, resolve_ffmpeg_path
|
||||
from frigate.util.config import resolve_ffmpeg_path
|
||||
from frigate.util.env import EnvString
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
@@ -101,16 +101,6 @@ class FfmpegConfig(FrigateBaseModel):
|
||||
description="Default GPU index used for hardware acceleration if available.",
|
||||
)
|
||||
|
||||
@field_validator("path")
|
||||
@classmethod
|
||||
def validate_path(cls, v: str) -> str:
|
||||
if not is_valid_ffmpeg_path(v):
|
||||
raise ValueError(
|
||||
"FFmpeg path must be a version alias or an absolute path without "
|
||||
"spaces, quotes or shell characters"
|
||||
)
|
||||
return v
|
||||
|
||||
@property
|
||||
def ffmpeg_path(self) -> str:
|
||||
return resolve_ffmpeg_path(self.path, "ffmpeg")
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
"""Shared handle on the config object that is current for this instance."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from .config import FrigateConfig
|
||||
|
||||
__all__ = ["ConfigHolder"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigHolder:
|
||||
"""Indirection for the most recently parsed config.
|
||||
@@ -28,24 +23,12 @@ class ConfigHolder:
|
||||
|
||||
def __init__(self, config: FrigateConfig) -> None:
|
||||
self._config = config
|
||||
self._listeners: list[Callable[[FrigateConfig], None]] = []
|
||||
|
||||
@property
|
||||
def config(self) -> FrigateConfig:
|
||||
"""The config as of the most recent successful save."""
|
||||
return self._config
|
||||
|
||||
def subscribe(self, listener: Callable[[FrigateConfig], None]) -> None:
|
||||
"""Call listener on the saving thread with each config installed later."""
|
||||
self._listeners.append(listener)
|
||||
|
||||
def set(self, config: FrigateConfig) -> None:
|
||||
"""Install a freshly parsed config as the current one."""
|
||||
self._config = config
|
||||
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener(config)
|
||||
except Exception:
|
||||
# a listener bug must not fail the save that has already applied
|
||||
logger.exception("Config listener failed")
|
||||
|
||||
@@ -29,11 +29,6 @@ class StatsConfig(FrigateBaseModel):
|
||||
|
||||
|
||||
class TelemetryConfig(FrigateBaseModel):
|
||||
analytics: bool = Field(
|
||||
default=False,
|
||||
title="Share anonymous analytics",
|
||||
description="Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on.",
|
||||
)
|
||||
network_interfaces: list[str] = Field(
|
||||
default=[],
|
||||
title="Network interfaces",
|
||||
|
||||
@@ -102,9 +102,6 @@ MAX_WAL_SIZE = 10 # MB
|
||||
|
||||
DEFAULT_FFMPEG_VERSION = os.environ.get("DEFAULT_FFMPEG_VERSION", "")
|
||||
INCLUDED_FFMPEG_VERSIONS = os.environ.get("INCLUDED_FFMPEG_VERSIONS", "").split(":")
|
||||
ANALYTICS_URL = os.environ.get(
|
||||
"FRIGATE_ANALYTICS_URL", "https://analytics.frigate.video/report"
|
||||
)
|
||||
LIBAVFORMAT_VERSION_MAJOR = int(os.environ.get("LIBAVFORMAT_VERSION_MAJOR", "59"))
|
||||
FFMPEG_HWACCEL_NVIDIA = "preset-nvidia"
|
||||
FFMPEG_HWACCEL_VAAPI = "preset-vaapi"
|
||||
|
||||
@@ -7,8 +7,7 @@ overrides what is genuinely Azure-specific:
|
||||
|
||||
- Client construction: parses ``api-version`` out of the configured
|
||||
``base_url`` query string and instantiates :class:`openai.AzureOpenAI`
|
||||
(or :class:`openai.AsyncAzureOpenAI` for streaming chat) with
|
||||
``azure_endpoint`` instead of ``base_url``. Raises if the URL is
|
||||
with ``azure_endpoint`` instead of ``base_url``. Raises if the URL is
|
||||
malformed; :class:`GenAIClientManager` catches the exception and
|
||||
disables the provider.
|
||||
- Context size: Azure does not expose a per-model ``max_model_len`` field
|
||||
@@ -30,10 +29,9 @@ the ``transcribe`` role:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from openai import AsyncAzureOpenAI, AzureOpenAI
|
||||
from openai import AzureOpenAI
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import register_genai_provider
|
||||
@@ -46,8 +44,8 @@ logger = logging.getLogger(__name__)
|
||||
class AzureOpenAIClient(OpenAIClient):
|
||||
"""Generative AI client for Frigate using Azure OpenAI."""
|
||||
|
||||
def _client_options(self) -> dict[str, Any]:
|
||||
"""Build the Azure client options from the configured base_url."""
|
||||
def _init_provider(self) -> AzureOpenAI:
|
||||
"""Initialize the AzureOpenAI client from the configured base_url."""
|
||||
parsed_url = urlparse(self.genai_config.base_url or "")
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
api_version = query_params.get("api-version", [None])[0]
|
||||
@@ -55,19 +53,13 @@ class AzureOpenAIClient(OpenAIClient):
|
||||
if not api_version:
|
||||
raise ValueError("Azure OpenAI base_url is missing api-version.")
|
||||
|
||||
return {
|
||||
"api_key": self.genai_config.api_key,
|
||||
"api_version": api_version,
|
||||
"azure_endpoint": f"{parsed_url.scheme}://{parsed_url.netloc}/",
|
||||
}
|
||||
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
|
||||
|
||||
def _init_provider(self) -> AzureOpenAI:
|
||||
"""Initialize the AzureOpenAI client from the configured base_url."""
|
||||
return AzureOpenAI(**self._client_options())
|
||||
|
||||
def _init_async_provider(self) -> AsyncAzureOpenAI:
|
||||
"""Create an async Azure client for streaming chat."""
|
||||
return AsyncAzureOpenAI(**self._client_options())
|
||||
return AzureOpenAI(
|
||||
api_key=self.genai_config.api_key,
|
||||
api_version=api_version,
|
||||
azure_endpoint=azure_endpoint,
|
||||
)
|
||||
|
||||
def get_context_size(self) -> int:
|
||||
"""Azure does not reliably surface per-model context size; use 128K."""
|
||||
|
||||
@@ -2,17 +2,14 @@
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from google import genai
|
||||
from google.genai import errors, types
|
||||
from google.genai.types import FunctionCallingConfigMode
|
||||
from PIL import Image
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
@@ -24,11 +21,6 @@ logger = logging.getLogger(__name__)
|
||||
# under it so the request fails as a log line rather than a 400.
|
||||
GEMINI_MAX_INLINE_BYTES = 15 * 1024 * 1024
|
||||
|
||||
# Image-token probes run on the enrichment thread, so they get a short timeout,
|
||||
# no retries, and a cooldown after failure instead of the client's defaults.
|
||||
COUNT_TOKENS_TIMEOUT_MS = 5000
|
||||
COUNT_TOKENS_RETRY_INTERVAL = 300
|
||||
|
||||
|
||||
def _decode_thought_signature(value: Any) -> bytes | None:
|
||||
"""Decode a base64-encoded thought_signature carried across conversation turns."""
|
||||
@@ -101,14 +93,9 @@ class GeminiClient(GenAIClient):
|
||||
"""Generative AI client for Frigate using Gemini."""
|
||||
|
||||
provider: genai.Client
|
||||
_image_token_cache: dict[tuple[int, int], int]
|
||||
_image_token_retry_at: float
|
||||
|
||||
def _init_provider(self) -> genai.Client:
|
||||
"""Initialize the client."""
|
||||
self._image_token_cache = {}
|
||||
self._image_token_retry_at = 0.0
|
||||
|
||||
# Merge provider_options into HttpOptions
|
||||
http_options_dict: dict[str, Any] = {
|
||||
"timeout": int(self.timeout * 1000), # requires milliseconds
|
||||
@@ -245,71 +232,6 @@ class GeminiClient(GenAIClient):
|
||||
# Gemini Pro Vision has a 1M token context window
|
||||
return 1000000
|
||||
|
||||
def estimate_image_tokens(self, width: int, height: int) -> float:
|
||||
"""Ask Gemini how many prompt tokens an image of these dimensions costs.
|
||||
|
||||
Gemini bills images far above the base pixel heuristic (Gemini 3 models
|
||||
charge ~1100 tokens for a 320x180 frame, not ~46), and the cost differs
|
||||
between model generations, so it is measured with the free count_tokens
|
||||
endpoint and cached per (width, height). Falls back to the base heuristic
|
||||
if the request fails, and does not probe again for
|
||||
COUNT_TOKENS_RETRY_INTERVAL seconds.
|
||||
"""
|
||||
cached = self._image_token_cache.get((width, height))
|
||||
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if time.monotonic() < self._image_token_retry_at:
|
||||
return super().estimate_image_tokens(width, height)
|
||||
|
||||
img = Image.new("RGB", (width, height), (128, 128, 128))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=60)
|
||||
image = types.Part.from_bytes(data=buf.getvalue(), mime_type="image/jpeg")
|
||||
|
||||
try:
|
||||
baseline = self._count_tokens(["."])
|
||||
with_image = self._count_tokens([".", image])
|
||||
tokens = max(1, with_image - baseline)
|
||||
except Exception as e:
|
||||
self._image_token_retry_at = time.monotonic() + COUNT_TOKENS_RETRY_INTERVAL
|
||||
logger.debug(
|
||||
"Gemini image-token count failed for %dx%d (%s); using heuristic",
|
||||
width,
|
||||
height,
|
||||
e,
|
||||
)
|
||||
return super().estimate_image_tokens(width, height)
|
||||
|
||||
self._image_token_cache[(width, height)] = tokens
|
||||
logger.debug(
|
||||
"Gemini model '%s' uses %d tokens for %dx%d images",
|
||||
self.genai_config.model,
|
||||
tokens,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
return tokens
|
||||
|
||||
def _count_tokens(self, contents: list[Any]) -> int:
|
||||
"""Return Gemini's prompt-token count for contents, without generating."""
|
||||
total = self.provider.models.count_tokens(
|
||||
model=self.genai_config.model,
|
||||
contents=contents,
|
||||
config=types.CountTokensConfig(
|
||||
http_options=types.HttpOptions(
|
||||
timeout=COUNT_TOKENS_TIMEOUT_MS,
|
||||
retry_options=types.HttpRetryOptions(attempts=1),
|
||||
)
|
||||
),
|
||||
).total_tokens
|
||||
|
||||
if total is None:
|
||||
raise ValueError("count_tokens returned no total")
|
||||
|
||||
return total
|
||||
|
||||
def chat_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from httpx import TimeoutException
|
||||
from openai import AsyncOpenAI, OpenAI
|
||||
from openai import OpenAI
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
@@ -15,7 +15,6 @@ from frigate.genai.utils import interleave_images
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# gpt-transcribe replaced the singular `language` field with a `languages` array
|
||||
# and rejects a request that sends both. Older transcription models
|
||||
# (gpt-4o-transcribe, gpt-4o-mini-transcribe, whisper-1) still take the singular
|
||||
@@ -46,8 +45,8 @@ class OpenAIClient(GenAIClient):
|
||||
provider: OpenAI
|
||||
context_size: int | None = None
|
||||
|
||||
def _client_options(self) -> dict[str, Any]:
|
||||
"""Build the constructor options shared by the sync and async clients.
|
||||
def _init_provider(self) -> OpenAI:
|
||||
"""Initialize the client.
|
||||
|
||||
Subclasses (e.g. Azure) should raise on configuration errors; the
|
||||
manager catches construction failures and disables the provider.
|
||||
@@ -63,19 +62,7 @@ class OpenAIClient(GenAIClient):
|
||||
if self.genai_config.base_url:
|
||||
provider_opts["base_url"] = self.genai_config.base_url
|
||||
|
||||
return {"api_key": self.genai_config.api_key, **provider_opts}
|
||||
|
||||
def _init_provider(self) -> OpenAI:
|
||||
"""Initialize the client."""
|
||||
return OpenAI(**self._client_options())
|
||||
|
||||
def _init_async_provider(self) -> AsyncOpenAI:
|
||||
"""Create an async client for streaming chat.
|
||||
|
||||
A new client is created per stream because its connection pool binds
|
||||
to the event loop that first uses it.
|
||||
"""
|
||||
return AsyncOpenAI(**self._client_options())
|
||||
return OpenAI(api_key=self.genai_config.api_key, **provider_opts)
|
||||
|
||||
def _send(
|
||||
self,
|
||||
@@ -425,61 +412,57 @@ class OpenAIClient(GenAIClient):
|
||||
finish_reason = "stop"
|
||||
usage_stats: dict[str, Any] | None = None
|
||||
|
||||
# Exiting these blocks, including when the caller disconnects and
|
||||
# the generator is cancelled, closes the response and its connection
|
||||
async with (
|
||||
self._init_async_provider() as client,
|
||||
await client.chat.completions.create(**request_params) as stream,
|
||||
):
|
||||
async for chunk in stream:
|
||||
chunk_usage = getattr(chunk, "usage", None)
|
||||
if chunk_usage is not None:
|
||||
usage_stats = _stats_from_openai_usage(chunk_usage)
|
||||
stream = self.provider.chat.completions.create(**request_params)
|
||||
|
||||
if not chunk or not chunk.choices:
|
||||
continue
|
||||
for chunk in stream:
|
||||
chunk_usage = getattr(chunk, "usage", None)
|
||||
if chunk_usage is not None:
|
||||
usage_stats = _stats_from_openai_usage(chunk_usage)
|
||||
|
||||
choice = chunk.choices[0]
|
||||
delta = choice.delta
|
||||
if not chunk or not chunk.choices:
|
||||
continue
|
||||
|
||||
# Check for finish reason
|
||||
if choice.finish_reason:
|
||||
finish_reason = choice.finish_reason
|
||||
choice = chunk.choices[0]
|
||||
delta = choice.delta
|
||||
|
||||
# Extract reasoning deltas (reasoning_content or reasoning,
|
||||
# depending on the server)
|
||||
reasoning_delta = getattr(
|
||||
delta, "reasoning_content", None
|
||||
) or getattr(delta, "reasoning", None)
|
||||
if reasoning_delta:
|
||||
reasoning_parts.append(reasoning_delta)
|
||||
yield ("reasoning_delta", reasoning_delta)
|
||||
# Check for finish reason
|
||||
if choice.finish_reason:
|
||||
finish_reason = choice.finish_reason
|
||||
|
||||
# Extract content deltas
|
||||
if delta.content:
|
||||
content_parts.append(delta.content)
|
||||
yield ("content_delta", delta.content)
|
||||
# Extract reasoning deltas (reasoning_content or reasoning,
|
||||
# depending on the server)
|
||||
reasoning_delta = getattr(delta, "reasoning_content", None) or getattr(
|
||||
delta, "reasoning", None
|
||||
)
|
||||
if reasoning_delta:
|
||||
reasoning_parts.append(reasoning_delta)
|
||||
yield ("reasoning_delta", reasoning_delta)
|
||||
|
||||
# Extract tool calls
|
||||
if delta.tool_calls:
|
||||
for tc in delta.tool_calls:
|
||||
idx = tc.index
|
||||
fn = tc.function
|
||||
# Extract content deltas
|
||||
if delta.content:
|
||||
content_parts.append(delta.content)
|
||||
yield ("content_delta", delta.content)
|
||||
|
||||
if idx not in tool_calls_by_index:
|
||||
tool_calls_by_index[idx] = {
|
||||
"id": tc.id or "",
|
||||
"name": fn.name if fn and fn.name else "",
|
||||
"arguments": "",
|
||||
}
|
||||
# Extract tool calls
|
||||
if delta.tool_calls:
|
||||
for tc in delta.tool_calls:
|
||||
idx = tc.index
|
||||
fn = tc.function
|
||||
|
||||
t = tool_calls_by_index[idx]
|
||||
if tc.id:
|
||||
t["id"] = tc.id
|
||||
if fn and fn.name:
|
||||
t["name"] = fn.name
|
||||
if fn and fn.arguments:
|
||||
t["arguments"] += fn.arguments
|
||||
if idx not in tool_calls_by_index:
|
||||
tool_calls_by_index[idx] = {
|
||||
"id": tc.id or "",
|
||||
"name": fn.name if fn and fn.name else "",
|
||||
"arguments": "",
|
||||
}
|
||||
|
||||
t = tool_calls_by_index[idx]
|
||||
if tc.id:
|
||||
t["id"] = tc.id
|
||||
if fn and fn.name:
|
||||
t["name"] = fn.name
|
||||
if fn and fn.arguments:
|
||||
t["arguments"] += fn.arguments
|
||||
|
||||
# Build final message
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
|
||||
+1
-1
@@ -209,7 +209,7 @@ class NoticeStats(Model):
|
||||
mutes = IntegerField(default=0)
|
||||
first_seen = DateTimeField()
|
||||
last_seen = DateTimeField()
|
||||
# lifetime counts when the current analytics install ID was created
|
||||
# watermarks for a future analytics reporter; unused until then
|
||||
reported_occurrences = IntegerField(default=0)
|
||||
reported_acknowledgements = IntegerField(default=0)
|
||||
reported_mutes = IntegerField(default=0)
|
||||
|
||||
+18
-38
@@ -132,7 +132,24 @@ class NoticeRegistry:
|
||||
def flush(self) -> None:
|
||||
"""Write the repeats that batch_repeats kinds held back."""
|
||||
with self._lock:
|
||||
written = self._write_held()
|
||||
held, self._held = self._held, {}
|
||||
written = False
|
||||
|
||||
for row_id, repeats in held.items():
|
||||
row = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
# resolved while its repeats waited
|
||||
if row is None:
|
||||
continue
|
||||
|
||||
self._write_repeats(
|
||||
row,
|
||||
repeats.kind,
|
||||
repeats.count,
|
||||
repeats.last_seen,
|
||||
repeats.params,
|
||||
)
|
||||
written = True
|
||||
|
||||
if written:
|
||||
self._notify()
|
||||
@@ -368,43 +385,6 @@ class NoticeRegistry:
|
||||
if row.kind in NOTICE_KINDS
|
||||
]
|
||||
|
||||
def reset_analytics_baseline(self) -> None:
|
||||
"""Count analytics notices from now on, for a new analytics install ID."""
|
||||
with self._lock:
|
||||
# held repeats were raised before the reset, so they join the baseline
|
||||
written = self._write_held()
|
||||
NoticeStats.update(
|
||||
reported_occurrences=NoticeStats.occurrences,
|
||||
reported_acknowledgements=NoticeStats.acknowledgements,
|
||||
reported_mutes=NoticeStats.mutes,
|
||||
).execute()
|
||||
|
||||
if written:
|
||||
self._notify()
|
||||
|
||||
def _write_held(self) -> bool:
|
||||
# called with the lock held
|
||||
held, self._held = self._held, {}
|
||||
written = False
|
||||
|
||||
for row_id, repeats in held.items():
|
||||
row = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
# resolved while its repeats waited
|
||||
if row is None:
|
||||
continue
|
||||
|
||||
self._write_repeats(
|
||||
row,
|
||||
repeats.kind,
|
||||
repeats.count,
|
||||
repeats.last_seen,
|
||||
repeats.params,
|
||||
)
|
||||
written = True
|
||||
|
||||
return written
|
||||
|
||||
def _write_repeats(
|
||||
self,
|
||||
row: Notice,
|
||||
|
||||
@@ -99,16 +99,6 @@ _KINDS = (
|
||||
link="https://github.com/blakeblackshear/frigate/releases/tag/v{version}",
|
||||
counts_repeats=False,
|
||||
keep_latest=1,
|
||||
reportable=False,
|
||||
),
|
||||
# raised while analytics is off; the row keeps a dismissal across restarts
|
||||
NoticeKind(
|
||||
"analytics_prompt",
|
||||
NoticeSeverity.info,
|
||||
"system",
|
||||
link="/settings?page=systemTelemetry",
|
||||
counts_repeats=False,
|
||||
reportable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from frigate.detectors.detector_config import (
|
||||
InputDTypeEnum,
|
||||
ModelConfig,
|
||||
)
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, load_labels
|
||||
from frigate.util.builtin import EventsPerSecond, load_labels
|
||||
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
|
||||
from frigate.util.process import FrigateProcess
|
||||
|
||||
@@ -128,7 +128,7 @@ class DetectorRunner(FrigateProcess):
|
||||
super().__init__(stop_event, PROCESS_PRIORITY_HIGH, name=name, daemon=True)
|
||||
self.detection_queue = detection_queue
|
||||
self.cameras = cameras
|
||||
self.avg_speed = InferenceSpeed(avg_speed)
|
||||
self.avg_speed = avg_speed
|
||||
self.start_time = start_time
|
||||
self.config = config
|
||||
self.detector_config = detector_config
|
||||
@@ -182,7 +182,7 @@ class DetectorRunner(FrigateProcess):
|
||||
detector_publisher.publish(connection_id)
|
||||
self.start_time.value = 0.0
|
||||
|
||||
self.avg_speed.update(duration)
|
||||
self.avg_speed.value = (self.avg_speed.value * 9 + duration) / 10
|
||||
|
||||
detector_publisher.stop()
|
||||
logger.info("Exited detection process...")
|
||||
@@ -203,7 +203,7 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
super().__init__(stop_event, PROCESS_PRIORITY_HIGH, name=name, daemon=True)
|
||||
self.detection_queue = detection_queue
|
||||
self.cameras = cameras
|
||||
self.avg_speed = InferenceSpeed(avg_speed)
|
||||
self.avg_speed = avg_speed
|
||||
self.start_time = start_time
|
||||
self.config = config
|
||||
self.detector_config = detector_config
|
||||
@@ -272,7 +272,7 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
self._publisher.publish(connection_id)
|
||||
|
||||
# update timers
|
||||
self.avg_speed.update(duration)
|
||||
self.avg_speed.value = (self.avg_speed.value * 9 + duration) / 10
|
||||
self.start_time.value = 0.0
|
||||
|
||||
def run(self) -> None:
|
||||
@@ -328,8 +328,7 @@ class ObjectDetectProcess:
|
||||
self.name = name
|
||||
self.cameras = cameras
|
||||
self.detection_queue = detection_queue
|
||||
# stays 0 until the first inference, so stats can tell an idle detector
|
||||
self.avg_inference_speed = Value("d", 0.0)
|
||||
self.avg_inference_speed = Value("d", 0.01)
|
||||
self.detection_start = Value("d", 0.0)
|
||||
self.detect_process: FrigateProcess | None = None
|
||||
self.config = config
|
||||
|
||||
+25
-31
@@ -233,8 +233,7 @@ class PtzAutoTracker(threading.Thread):
|
||||
and camera_config.onvif.autotracking.enabled_in_config
|
||||
):
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._autotracker_setup(camera_config, camera, startup=True),
|
||||
self.onvif.loop,
|
||||
self._autotracker_setup(camera_config, camera), self.onvif.loop
|
||||
)
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
@@ -262,9 +261,7 @@ class PtzAutoTracker(threading.Thread):
|
||||
self.config_subscriber.stop()
|
||||
logger.info("Exiting autotracker...")
|
||||
|
||||
async def _autotracker_setup(
|
||||
self, camera_config: CameraConfig, camera: str, startup: bool = False
|
||||
):
|
||||
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
|
||||
logger.debug(f"{camera}: Autotracker init")
|
||||
|
||||
self.tracked_object[camera] = None
|
||||
@@ -291,34 +288,35 @@ class PtzAutoTracker(threading.Thread):
|
||||
self._disable(camera, "Unable to initialize onvif")
|
||||
return
|
||||
|
||||
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
|
||||
self._disable(camera, "FOV relative movement not supported")
|
||||
return
|
||||
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
|
||||
self._disable(camera, "FOV relative movement not supported")
|
||||
return
|
||||
|
||||
move_status_supported = await self.onvif.get_service_capabilities(camera)
|
||||
move_status_supported = await self.onvif.get_service_capabilities(camera)
|
||||
|
||||
if str(move_status_supported).lower() != "true":
|
||||
self._disable(camera, "ONVIF MoveStatus not supported")
|
||||
return
|
||||
if str(move_status_supported).lower() != "true":
|
||||
self._disable(camera, "ONVIF MoveStatus not supported")
|
||||
return
|
||||
|
||||
await self.onvif.get_camera_status(camera)
|
||||
if self.onvif.cams[camera]["init"]:
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
# movement queue with asyncio on OnvifController loop
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._process_move_queue(camera), self.onvif.loop
|
||||
)
|
||||
# movement queue with asyncio on OnvifController loop
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._process_move_queue(camera), self.onvif.loop
|
||||
)
|
||||
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
(
|
||||
self.ptz_metrics[camera].min_zoom.value,
|
||||
self.ptz_metrics[camera].max_zoom.value,
|
||||
self.intercept[camera],
|
||||
*self.move_coefficients[camera],
|
||||
self.zoom_time[camera],
|
||||
) = map(float, camera_config.onvif.autotracking.movement_weights)
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
(
|
||||
self.ptz_metrics[camera].min_zoom.value,
|
||||
self.ptz_metrics[camera].max_zoom.value,
|
||||
self.intercept[camera],
|
||||
*self.move_coefficients[camera],
|
||||
self.zoom_time[camera],
|
||||
) = map(float, camera_config.onvif.autotracking.movement_weights)
|
||||
|
||||
if startup and camera_config.onvif.autotracking.calibrate_on_startup:
|
||||
await self._calibrate_camera(camera)
|
||||
if camera_config.onvif.autotracking.calibrate_on_startup:
|
||||
await self._calibrate_camera(camera)
|
||||
|
||||
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
|
||||
self.autotracker_init[camera] = True
|
||||
@@ -333,7 +331,6 @@ class PtzAutoTracker(threading.Thread):
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
|
||||
autotracking_config,
|
||||
)
|
||||
self.dispatcher.publish(f"{camera}/ptz_autotracker/state", "OFF", retain=True)
|
||||
|
||||
def _reset_tracked_object_metrics(self, camera: str) -> None:
|
||||
self.tracked_object_metrics[camera] = {}
|
||||
@@ -1272,9 +1269,6 @@ class PtzAutoTracker(threading.Thread):
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
|
||||
if not camera_config.onvif.autotracking.enabled:
|
||||
return
|
||||
|
||||
if self.calibrating[camera]:
|
||||
logger.debug(f"{camera}: Calibrating camera")
|
||||
return
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Shared fixtures for the analytics tests."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
FRONT_CAMERA: dict[str, Any] = {
|
||||
"ffmpeg": {"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]},
|
||||
"detect": {"height": 720, "width": 1280, "fps": 5},
|
||||
}
|
||||
|
||||
|
||||
def make_config(config: dict[str, Any] | None = None) -> FrigateConfig:
|
||||
"""A parsed config; top-level keys in config replace the defaults here.
|
||||
|
||||
hwaccel_args is set so parsing never probes the test host's GPU.
|
||||
"""
|
||||
base: dict[str, Any] = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"ffmpeg": {"hwaccel_args": []},
|
||||
"cameras": {"front": FRONT_CAMERA},
|
||||
}
|
||||
return FrigateConfig(**{**base, **(config or {})})
|
||||
|
||||
|
||||
def make_context(
|
||||
config: FrigateConfig | None = None,
|
||||
stats: dict[str, Any] | None = None,
|
||||
notice_stats: list[dict[str, Any]] | None = None,
|
||||
) -> ReportContext:
|
||||
return ReportContext(
|
||||
config=config or make_config(),
|
||||
stats=stats or {},
|
||||
notice_stats=notice_stats or [],
|
||||
)
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Tests for the analytics preview API."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from frigate.analytics import report
|
||||
from frigate.analytics.collectors import hardware
|
||||
from frigate.models import Notice, NoticeStats, User
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpAnalytics(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Notice, NoticeStats, User])
|
||||
stats = Mock()
|
||||
stats.get_latest_stats.return_value = self.test_stats
|
||||
self.app = self.create_app(stats=stats, notice_registry=NoticeRegistry())
|
||||
|
||||
for target, name, value in (
|
||||
(report, "load_state", None),
|
||||
(hardware.hardware_prober, "probe", []),
|
||||
(hardware, "hwaccel_options", ("", [])),
|
||||
):
|
||||
patcher = patch.object(target, name, return_value=value)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_preview_returns_the_report_without_sending_it(self):
|
||||
with (
|
||||
patch("frigate.analytics.transport.requests.post") as post,
|
||||
AuthTestClient(self.app) as client,
|
||||
):
|
||||
response = client.get("/analytics/preview")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["install_id"], report.PREVIEW_INSTALL_ID)
|
||||
self.assertEqual(body["schema_version"], 1)
|
||||
self.assertEqual(body["cameras"]["total"], 1)
|
||||
post.assert_not_called()
|
||||
|
||||
def test_preview_is_admin_only(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.get(
|
||||
"/analytics/preview",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
@@ -94,23 +94,6 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
|
||||
def _set_go2rtc(self, go2rtc: dict):
|
||||
with AuthTestClient(self.app) as client:
|
||||
return client.put(
|
||||
"/config/set",
|
||||
json={"config_data": {"go2rtc": go2rtc}, "requires_restart": 1},
|
||||
)
|
||||
|
||||
def _save_go2rtc(self, go2rtc: dict):
|
||||
config = copy.deepcopy(_CONFIG)
|
||||
config["go2rtc"].update(go2rtc)
|
||||
with AuthTestClient(self.app) as client:
|
||||
return client.post(
|
||||
"/config/save?save_option=saveonly",
|
||||
content=_dump(config),
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
|
||||
# --- config/set ---
|
||||
|
||||
def test_set_rejects_injected_ffmpeg_source(self):
|
||||
@@ -241,108 +224,3 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
|
||||
resp = self._set_streams({"new_stream": [src]})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert src in self._read_config()
|
||||
|
||||
# --- go2rtc ffmpeg and ngrok options ---
|
||||
|
||||
def test_save_rejects_ffmpeg_bin_override(self):
|
||||
before = self._read_config()
|
||||
resp = self._save_go2rtc({"ffmpeg": {"bin": "/bin/sh", "global": "-c 'id' x"}})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["message"] == (
|
||||
"Restricted go2rtc option: ffmpeg.bin, ffmpeg.global"
|
||||
)
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_set_rejects_ffmpeg_bin_override(self):
|
||||
before = self._read_config()
|
||||
resp = self._set_go2rtc({"ffmpeg": {"bin": "/bin/sh"}})
|
||||
assert resp.status_code == 400
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_save_rejects_ffmpeg_template_override(self):
|
||||
before = self._read_config()
|
||||
resp = self._save_go2rtc({"ffmpeg": {"rtsp": "-i {input} -y /config/x"}})
|
||||
assert resp.status_code == 400
|
||||
assert "ffmpeg.rtsp" in resp.json()["message"]
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_set_rejects_custom_ffmpeg_template(self):
|
||||
# a stream can reference any template key with #video=<name>
|
||||
before = self._read_config()
|
||||
resp = self._set_go2rtc({"ffmpeg": {"evil": "-y /config/x"}})
|
||||
assert resp.status_code == 400
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_save_rejects_ngrok(self):
|
||||
before = self._read_config()
|
||||
resp = self._save_go2rtc({"ngrok": {"command": "/bin/sh -c id"}})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["message"] == "Restricted go2rtc option: ngrok.command"
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_set_rejects_ngrok(self):
|
||||
before = self._read_config()
|
||||
resp = self._set_go2rtc({"ngrok": {"command": "/bin/sh -c id"}})
|
||||
assert resp.status_code == 400
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_save_keeps_existing_ffmpeg_template_on_disk(self):
|
||||
go2rtc = {"ffmpeg": {"global": "-vaapi_device /dev/dri/renderD128"}}
|
||||
config = copy.deepcopy(_CONFIG)
|
||||
config["go2rtc"].update(go2rtc)
|
||||
with open(self.config_path, "w") as f:
|
||||
f.write(_dump(config))
|
||||
|
||||
resp = self._save_go2rtc(go2rtc)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
resp = self._save_go2rtc({"ffmpeg": {"global": "-hide_banner"}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_exec_options_allowed_when_override_set(self):
|
||||
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
|
||||
resp = self._save_go2rtc(
|
||||
{
|
||||
"ffmpeg": {"bin": "/usr/bin/ffmpeg", "rtsp": "-i {input}"},
|
||||
"ngrok": {"command": "ngrok tcp 8555"},
|
||||
}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "ngrok tcp 8555" in self._read_config()
|
||||
|
||||
def test_save_rejects_unsafe_ffmpeg_path(self):
|
||||
before = self._read_config()
|
||||
config = copy.deepcopy(_CONFIG)
|
||||
config["ffmpeg"] = {"path": "/bin/sh -c id;#"}
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.post(
|
||||
"/config/save?save_option=saveonly",
|
||||
content=_dump(config),
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert self._read_config() == before
|
||||
|
||||
# --- go2rtc ${VAR} expansion ---
|
||||
|
||||
def test_set_rejects_var_expansion_source(self):
|
||||
before = self._read_config()
|
||||
resp = self._set_streams({"new_stream": "${NOPE:exec:touch /tmp/pwned}"})
|
||||
assert resp.status_code == 400
|
||||
assert "Restricted go2rtc stream source" in resp.json()["message"]
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_set_rejects_escaped_var_expansion_source(self):
|
||||
before = self._read_config()
|
||||
resp = self._set_streams({"new_stream": "${{NOPE:exec:id}}"})
|
||||
assert resp.status_code == 400
|
||||
assert "Restricted go2rtc stream source" in resp.json()["message"]
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_set_allows_var_expansion_source_when_override_set(self):
|
||||
with patch(
|
||||
"frigate.util.services.is_go2rtc_arbitrary_exec_allowed",
|
||||
return_value=True,
|
||||
):
|
||||
resp = self._set_streams({"new_stream": "${NOPE:exec:id}"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
"""Tests for the cameras collector."""
|
||||
|
||||
import unittest
|
||||
|
||||
from frigate.analytics.collectors import cameras
|
||||
from frigate.analytics.schema import HeightBucket, HwaccelKey, RetainBucket
|
||||
from frigate.test.analytics_helpers import make_config, make_context
|
||||
|
||||
MASK = {"coordinates": "0,0,1,0,1,1"}
|
||||
|
||||
CONFIG = {
|
||||
"audio_transcription": {"enabled": True},
|
||||
"record": {
|
||||
"enabled": True,
|
||||
"continuous": {"days": 3},
|
||||
"alerts": {"retain": {"days": 14}},
|
||||
"detections": {"retain": {"days": 45}},
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"hwaccel_args": "preset-vaapi",
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://127.0.0.1:8554/front_sub",
|
||||
"roles": ["detect"],
|
||||
"input_args": "preset-rtsp-restream",
|
||||
},
|
||||
{"path": "rtsp://127.0.0.1:8554/front", "roles": ["record"]},
|
||||
],
|
||||
},
|
||||
"detect": {"enabled": True, "height": 720, "width": 1280, "fps": 5},
|
||||
"zones": {"porch": MASK, "yard": MASK},
|
||||
"motion": {"mask": {"tree": MASK}},
|
||||
"objects": {
|
||||
"mask": {"sign": MASK},
|
||||
"filters": {"person": {"mask": {"bush": MASK}}},
|
||||
},
|
||||
"onvif": {"host": "10.0.0.9"},
|
||||
},
|
||||
"garage": {
|
||||
"type": "lpr",
|
||||
"ffmpeg": {
|
||||
"hwaccel_args": ["-hwaccel", "vaapi"],
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.5/stream",
|
||||
"roles": ["detect", "record", "audio"],
|
||||
}
|
||||
],
|
||||
},
|
||||
"audio": {"enabled": True},
|
||||
"detect": {"height": 2160, "width": 3840, "fps": 15},
|
||||
"record": {"enabled": False, "sub": {"enabled": True}},
|
||||
},
|
||||
"attic": {
|
||||
"enabled": False,
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.6/stream", "roles": ["detect"]}]
|
||||
},
|
||||
"detect": {"enabled": True, "height": 480, "width": 640, "fps": 5},
|
||||
},
|
||||
"_replay_front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.1/replay", "roles": ["detect"]}]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
STATS = {
|
||||
"cameras": {
|
||||
"front": {"connection_quality": "excellent"},
|
||||
"garage": {"connection_quality": "offline"},
|
||||
"attic": {"connection_quality": "unusable"},
|
||||
"_replay_front": {"connection_quality": "fair"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestCamerasCollector(unittest.TestCase):
|
||||
def test_aggregates_enabled_cameras_and_skips_replays(self):
|
||||
section = cameras.collect(make_context(make_config(CONFIG), STATS))
|
||||
|
||||
self.assertEqual((section.total, section.enabled), (3, 2))
|
||||
self.assertEqual(section.types, {"generic": 1, "lpr": 1})
|
||||
self.assertEqual(section.detect_height, {"720": 1, "ge_2160": 1})
|
||||
self.assertEqual(section.detect_fps, {"le_5": 1, "gt_10": 1})
|
||||
self.assertEqual(section.hwaccel, {"vaapi": 1, "custom": 1})
|
||||
self.assertEqual(section.input_preset, {"rtsp-restream": 1, "rtsp-generic": 1})
|
||||
self.assertEqual(section.go2rtc_restream, 1)
|
||||
self.assertEqual(section.separate_detect_stream, 1)
|
||||
self.assertEqual((section.detect, section.record, section.onvif), (1, 1, 1))
|
||||
self.assertEqual((section.zones, section.cameras_with_zones), (2, 1))
|
||||
self.assertEqual((section.motion_masks, section.object_masks), (1, 2))
|
||||
self.assertEqual((section.audio, section.audio_transcription), (1, 1))
|
||||
self.assertEqual(section.sub_stream_record, 0)
|
||||
self.assertEqual(section.connection_quality, {"excellent": 1})
|
||||
|
||||
def test_runtime_disabled_camera_skips_connection_quality(self):
|
||||
config = make_config(CONFIG)
|
||||
config.cameras["front"].enabled = False
|
||||
section = cameras.collect(make_context(config, STATS))
|
||||
|
||||
self.assertEqual(section.connection_quality, {})
|
||||
self.assertEqual(section.retain_days.continuous, {"1_7": 1})
|
||||
self.assertEqual(section.retain_days.motion, {"0": 1})
|
||||
self.assertEqual(section.retain_days.alerts, {"8_30": 1})
|
||||
self.assertEqual(section.retain_days.detections, {"gt_30": 1})
|
||||
|
||||
def test_buckets(self):
|
||||
self.assertEqual(cameras.height_bucket(360), HeightBucket.le_360)
|
||||
self.assertEqual(cameras.height_bucket(361), HeightBucket.h480)
|
||||
self.assertEqual(cameras.height_bucket(1080), HeightBucket.h1080)
|
||||
self.assertEqual(cameras.height_bucket(1440), HeightBucket.h1440)
|
||||
self.assertEqual(cameras.height_bucket(2160), HeightBucket.ge_2160)
|
||||
self.assertEqual(cameras.retain_bucket(0.5), RetainBucket.d1_7)
|
||||
self.assertEqual(cameras.retain_bucket(0), RetainBucket.zero)
|
||||
|
||||
def test_preset_key(self):
|
||||
self.assertEqual(cameras.preset_key([], HwaccelKey), "none")
|
||||
self.assertEqual(cameras.preset_key("", HwaccelKey), "none")
|
||||
self.assertEqual(cameras.preset_key("preset-bogus", HwaccelKey), "custom")
|
||||
self.assertEqual(cameras.preset_key("-hwaccel vaapi", HwaccelKey), "custom")
|
||||
self.assertEqual(cameras.preset_key("preset-nvidia", HwaccelKey), "nvidia")
|
||||
|
||||
def test_is_restream(self):
|
||||
self.assertTrue(cameras.is_restream("rtsp://127.0.0.1:8554/front"))
|
||||
self.assertTrue(cameras.is_restream("rtsp://localhost:8554/front"))
|
||||
self.assertFalse(cameras.is_restream("rtsp://10.0.0.5:8554/front"))
|
||||
self.assertFalse(cameras.is_restream("rtsp://[bad"))
|
||||
self.assertFalse(cameras.is_restream("rtsp://127.0.0.1:notaport/x"))
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Tests for the detection collector."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.analytics.collectors import detection
|
||||
from frigate.analytics.schema import ModelSource
|
||||
from frigate.test.analytics_helpers import make_config, make_context
|
||||
|
||||
|
||||
class TestDetectionCollector(unittest.TestCase):
|
||||
def test_reports_each_model_with_its_mean_inference_speed(self):
|
||||
config = make_config({"models": [{"devices": ["cpu", "cpu"]}]})
|
||||
stats = {
|
||||
"detectors": {
|
||||
"cpu": {"inference_speed": 10.0},
|
||||
"cpu#2": {"inference_speed": 20.0},
|
||||
},
|
||||
"detection_fps": 12.346,
|
||||
"skipped_fps": 0,
|
||||
}
|
||||
|
||||
section = detection.collect(make_context(config, stats))
|
||||
|
||||
[model] = section.models
|
||||
self.assertTrue(model.default)
|
||||
self.assertEqual(model.detector, "cpu")
|
||||
self.assertEqual(model.devices, 2)
|
||||
self.assertEqual(model.model_type, "ssd")
|
||||
self.assertEqual(model.input, "320x320")
|
||||
self.assertEqual(model.source, ModelSource.default)
|
||||
self.assertEqual(model.inference_ms, 15.0)
|
||||
self.assertEqual(section.detection_fps, 12.35)
|
||||
self.assertEqual(section.skipped_fps, 0.0)
|
||||
|
||||
def test_idle_detector_reports_no_speed(self):
|
||||
config = make_config({"models": [{"devices": ["cpu"]}]})
|
||||
stats = {"detectors": {"cpu": {"inference_speed": 0.0}}}
|
||||
|
||||
[model] = detection.collect(make_context(config, stats)).models
|
||||
self.assertIsNone(model.inference_ms)
|
||||
|
||||
def test_named_scene_reports_no_name(self):
|
||||
config = make_config(
|
||||
{
|
||||
"models": [
|
||||
{"devices": ["cpu"]},
|
||||
{
|
||||
"scene": "backyard_thermal",
|
||||
"devices": ["cpu"],
|
||||
"path": "/thermal.tflite",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
section = detection.collect(make_context(config))
|
||||
|
||||
self.assertEqual([m.default for m in section.models], [True, False])
|
||||
self.assertNotIn("backyard_thermal", section.model_dump_json())
|
||||
|
||||
def test_inference_is_null_before_the_first_stats(self):
|
||||
section = detection.collect(make_context())
|
||||
|
||||
self.assertIsNone(section.models[0].inference_ms)
|
||||
|
||||
def test_model_source(self):
|
||||
with tempfile.TemporaryDirectory() as cache:
|
||||
plus_model = os.path.join(cache, "abc123")
|
||||
open(f"{plus_model}.json", "w").close()
|
||||
|
||||
with patch.object(detection, "MODEL_CACHE_DIR", cache):
|
||||
self.assertEqual(detection.model_source(plus_model), ModelSource.plus)
|
||||
self.assertEqual(
|
||||
detection.model_source(os.path.join(cache, "no_info")),
|
||||
ModelSource.custom,
|
||||
)
|
||||
|
||||
self.assertEqual(detection.model_source(None), ModelSource.default)
|
||||
self.assertEqual(
|
||||
detection.model_source("/cpu_model.tflite"), ModelSource.default
|
||||
)
|
||||
self.assertEqual(
|
||||
detection.model_source("/config/yolo.onnx"), ModelSource.custom
|
||||
)
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Tests for the features collector."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from peewee import SqliteDatabase
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
from frigate.analytics.collectors import features
|
||||
from frigate.analytics.schema import EnrichmentDevice, SemanticSearchModel
|
||||
from frigate.models import User
|
||||
from frigate.test.analytics_helpers import FRONT_CAMERA, make_config, make_context
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
|
||||
CONFIG = {
|
||||
"mqtt": {"host": "mqtt", "enabled": True},
|
||||
"genai": {
|
||||
"local": {
|
||||
"provider": "ollama",
|
||||
"model": "llava",
|
||||
"base_url": "http://ollama:11434",
|
||||
"roles": ["descriptions", "embeddings"],
|
||||
}
|
||||
},
|
||||
"semantic_search": {"enabled": True, "model": "local"},
|
||||
"face_recognition": {"enabled": True, "model_size": "large"},
|
||||
"birdseye": {"enabled": True, "modes": ["motion", "alerts"], "restream": True},
|
||||
"proxy": {"header_map": {"user": "x-forwarded-user"}},
|
||||
"camera_groups": {"outside": {"cameras": ["front"], "icon": "LuCar", "order": 0}},
|
||||
"cameras": {
|
||||
"front": {
|
||||
**FRONT_CAMERA,
|
||||
"semantic_search": {
|
||||
"triggers": {
|
||||
"cat": {
|
||||
"type": "description",
|
||||
"data": "a cat",
|
||||
"threshold": 0.8,
|
||||
"actions": ["notification"],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestFeaturesCollector(unittest.TestCase):
|
||||
def setUp(self):
|
||||
migrate_db = SqliteDatabase(TEST_DB)
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
Router(migrate_db).run()
|
||||
migrate_db.close()
|
||||
self.db = SqliteQueueDatabase(TEST_DB)
|
||||
self.db.bind([User])
|
||||
|
||||
def tearDown(self):
|
||||
# close() leaves the queue's writer thread running against the deleted file
|
||||
self.db.stop()
|
||||
|
||||
if not self.db.is_closed():
|
||||
self.db.close()
|
||||
|
||||
for file in TEST_DB_CLEANUPS:
|
||||
try:
|
||||
os.remove(file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def test_collects_enrichments_genai_integrations_and_users(self):
|
||||
for username, role in (("a", "admin"), ("v", "viewer"), ("o", "operator")):
|
||||
User.create(
|
||||
username=username, role=role, password_hash="x", notification_tokens=[]
|
||||
)
|
||||
|
||||
stats = {
|
||||
"embeddings": {
|
||||
"devices": {
|
||||
"face_recognition": "OpenVINO GPU.0",
|
||||
"semantic_search": "CUDA",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section = features.collect(make_context(make_config(CONFIG), stats))
|
||||
|
||||
self.assertEqual(section.face_recognition.device, EnrichmentDevice.openvino_gpu)
|
||||
self.assertEqual(section.face_recognition.model_size, "large")
|
||||
self.assertIsNone(section.lpr.device)
|
||||
self.assertEqual(section.semantic_search.model, SemanticSearchModel.genai)
|
||||
self.assertEqual(section.semantic_search.device, EnrichmentDevice.cuda)
|
||||
self.assertEqual(section.semantic_search.triggers, 1)
|
||||
self.assertEqual(section.genai.providers, {"ollama": 1})
|
||||
self.assertEqual(section.genai.roles, {"descriptions": 1, "embeddings": 1})
|
||||
self.assertEqual(section.birdseye.modes, ["motion", "alerts"])
|
||||
self.assertTrue(section.mqtt)
|
||||
self.assertTrue(section.proxy_auth)
|
||||
self.assertEqual(section.users, {"admin": 1, "viewer": 1, "custom": 1})
|
||||
self.assertEqual((section.camera_groups, section.profiles), (1, 0))
|
||||
self.assertFalse(section.plus_api_key)
|
||||
|
||||
|
||||
class TestFeatureMappings(unittest.TestCase):
|
||||
def test_enrichment_device_labels(self):
|
||||
cases = {
|
||||
"CPU": EnrichmentDevice.cpu,
|
||||
"MIGraphX": EnrichmentDevice.migraphx,
|
||||
"OpenVINO NPU": EnrichmentDevice.openvino_npu,
|
||||
"OpenVINO GPU.0,CPU": EnrichmentDevice.openvino_gpu,
|
||||
"OpenVINO (CPU)": EnrichmentDevice.openvino_cpu,
|
||||
"OpenVINO": EnrichmentDevice.other,
|
||||
"CoreML": EnrichmentDevice.other,
|
||||
}
|
||||
|
||||
for label, device in cases.items():
|
||||
with self.subTest(label=label):
|
||||
self.assertEqual(features.enrichment_device(label), device)
|
||||
|
||||
self.assertIsNone(features.enrichment_device(None))
|
||||
|
||||
def test_models_named_after_a_provider_are_genai(self):
|
||||
self.assertEqual(features.semantic_model("jinav2"), SemanticSearchModel.jinav2)
|
||||
self.assertEqual(
|
||||
features.semantic_model("my-ollama"), SemanticSearchModel.genai
|
||||
)
|
||||
self.assertIsNone(features.semantic_model(None))
|
||||
self.assertEqual(features.transcription_model("openai"), "genai")
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Tests for the health collector."""
|
||||
|
||||
import unittest
|
||||
|
||||
from frigate.analytics.collectors import health
|
||||
from frigate.test.analytics_helpers import make_context
|
||||
|
||||
|
||||
class TestHealthCollector(unittest.TestCase):
|
||||
def test_collects_uptime_cpu_timings_and_notices_since_the_baseline(self):
|
||||
stats = {
|
||||
"service": {"uptime": 7300, "retention_unmet": True},
|
||||
"cpu_usages": {"frigate.full_system": {"cpu": "23.6", "mem": "40"}},
|
||||
"embeddings": {
|
||||
"face_recognition_speed": 41.234,
|
||||
"text_embedding_speed": 0,
|
||||
"devices": {},
|
||||
},
|
||||
}
|
||||
notice_stats = [
|
||||
{
|
||||
"kind": "detector_stuck",
|
||||
"occurrences": 5,
|
||||
"acknowledgements": 1,
|
||||
"mutes": 0,
|
||||
"reported_occurrences": 3,
|
||||
"reported_acknowledgements": 1,
|
||||
"reported_mutes": 0,
|
||||
},
|
||||
{
|
||||
"kind": "shm_too_low",
|
||||
"occurrences": 2,
|
||||
"acknowledgements": 0,
|
||||
"mutes": 0,
|
||||
"reported_occurrences": 2,
|
||||
"reported_acknowledgements": 0,
|
||||
"reported_mutes": 0,
|
||||
},
|
||||
{
|
||||
"kind": "update_available",
|
||||
"occurrences": 4,
|
||||
"acknowledgements": 0,
|
||||
"mutes": 0,
|
||||
"reported_occurrences": 0,
|
||||
"reported_acknowledgements": 0,
|
||||
"reported_mutes": 0,
|
||||
},
|
||||
]
|
||||
|
||||
section = health.collect(make_context(stats=stats, notice_stats=notice_stats))
|
||||
|
||||
self.assertEqual(section.uptime_hours, 2)
|
||||
self.assertEqual(section.cpu_percent, 24)
|
||||
self.assertEqual(section.enrichment_ms, {"face": 41.23})
|
||||
self.assertTrue(section.retention_unmet)
|
||||
self.assertEqual(
|
||||
{
|
||||
kind: (c.occurrences, c.acknowledgements, c.mutes)
|
||||
for kind, c in section.notices.items()
|
||||
},
|
||||
{"detector_stuck": (2, 0, 0)},
|
||||
)
|
||||
|
||||
def test_missing_stats_give_zeros(self):
|
||||
section = health.collect(make_context())
|
||||
|
||||
self.assertEqual(
|
||||
(section.uptime_hours, section.cpu_percent, section.enrichment_ms),
|
||||
(0, 0, {}),
|
||||
)
|
||||
self.assertEqual(section.notices, {})
|
||||
@@ -1,188 +0,0 @@
|
||||
"""Tests for the install and hardware collectors."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import Counter
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.analytics.collectors import hardware, install
|
||||
from frigate.analytics.collectors.common import closed, histogram, rate
|
||||
from frigate.analytics.schema import (
|
||||
Arch,
|
||||
DecodeFamily,
|
||||
GpuVendor,
|
||||
HardwareKey,
|
||||
ImageVariant,
|
||||
InstallType,
|
||||
)
|
||||
from frigate.const import RECORD_DIR
|
||||
from frigate.test.analytics_helpers import make_context
|
||||
|
||||
|
||||
class TestCommon(unittest.TestCase):
|
||||
def test_closed_falls_back_for_unknown_values(self):
|
||||
self.assertEqual(closed(Arch, "x86_64", Arch.other), Arch.x86_64)
|
||||
self.assertEqual(closed(Arch, "sparc", Arch.other), Arch.other)
|
||||
self.assertEqual(closed(Arch, None, Arch.other), Arch.other)
|
||||
|
||||
def test_rate_rounds_and_rejects_bad_numbers(self):
|
||||
self.assertEqual(rate("12.346"), 12.35)
|
||||
self.assertEqual(rate(-3), 0.0)
|
||||
self.assertEqual(rate(float("nan")), 0.0)
|
||||
self.assertEqual(rate(float("inf")), 0.0)
|
||||
self.assertEqual(rate(None), 0.0)
|
||||
|
||||
def test_histogram_drops_empty_buckets(self):
|
||||
self.assertEqual(
|
||||
histogram(Counter({Arch.x86_64: 2, Arch.other: 0})), {"x86_64": 2}
|
||||
)
|
||||
|
||||
|
||||
class TestInstallCollector(unittest.TestCase):
|
||||
def test_collects_version_variant_and_platform(self):
|
||||
with (
|
||||
patch.dict(os.environ, {"FRIGATE_IMAGE_VARIANT": "tensorrt-jp6"}),
|
||||
patch.object(install, "install_type", return_value=InstallType.docker),
|
||||
patch("platform.machine", return_value="aarch64"),
|
||||
patch("platform.release", return_value="6.8.0-45-generic"),
|
||||
patch("os.geteuid", return_value=1000),
|
||||
):
|
||||
section = install.collect(make_context())
|
||||
|
||||
self.assertEqual(section.image_variant, ImageVariant.tensorrt_jp6)
|
||||
self.assertEqual(section.install_type, InstallType.docker)
|
||||
self.assertEqual(section.arch, Arch.aarch64)
|
||||
self.assertEqual(section.kernel, "6.8")
|
||||
self.assertFalse(section.run_as_root)
|
||||
|
||||
def test_image_variant_is_dev_when_unset_and_other_when_unknown(self):
|
||||
self.assertEqual(install.image_variant(None), ImageVariant.dev)
|
||||
self.assertEqual(install.image_variant(""), ImageVariant.dev)
|
||||
self.assertEqual(install.image_variant("h8l"), ImageVariant.other)
|
||||
|
||||
def test_install_type_checks_the_add_on_first(self):
|
||||
with patch("os.path.isfile", return_value=True):
|
||||
self.assertEqual(install.install_type(), InstallType.ha_addon)
|
||||
|
||||
with (
|
||||
patch("os.path.isfile", return_value=False),
|
||||
patch.dict(os.environ, {"KUBERNETES_SERVICE_HOST": "10.0.0.1"}),
|
||||
):
|
||||
self.assertEqual(install.install_type(), InstallType.kubernetes)
|
||||
|
||||
env = {k: v for k, v in os.environ.items() if k != "KUBERNETES_SERVICE_HOST"}
|
||||
|
||||
with (
|
||||
patch("os.path.isfile", return_value=False),
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch("os.path.exists", side_effect=lambda path: path == "/.dockerenv"),
|
||||
):
|
||||
self.assertEqual(install.install_type(), InstallType.docker)
|
||||
|
||||
def test_kernel_and_arch_fall_back(self):
|
||||
self.assertEqual(install.kernel("weird"), "unknown")
|
||||
self.assertEqual(install.arch("armv7l"), Arch.other)
|
||||
self.assertEqual(install.arch("amd64"), Arch.x86_64)
|
||||
|
||||
|
||||
class TestHardwareCollector(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.dir.cleanup)
|
||||
|
||||
def write(self, name: str, content: str) -> str:
|
||||
path = os.path.join(self.dir.name, name)
|
||||
|
||||
with open(path, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
return path
|
||||
|
||||
def test_cpu_model_prefers_the_board_model(self):
|
||||
board = self.write("model", "Raspberry Pi 5 Model B Rev 1.0\x00")
|
||||
cpuinfo = self.write("cpuinfo", "model name\t: Cortex-A76\n")
|
||||
|
||||
with (
|
||||
patch.object(hardware, "DEVICE_TREE_MODEL", board),
|
||||
patch.object(hardware, "CPUINFO", cpuinfo),
|
||||
):
|
||||
self.assertEqual(hardware.cpu_model(), "Raspberry Pi 5 Model B Rev 1.0")
|
||||
|
||||
def test_cpu_model_reads_cpuinfo_and_truncates(self):
|
||||
cpuinfo = self.write("cpuinfo", "processor\t: 0\nmodel name\t: " + "x" * 80)
|
||||
|
||||
with (
|
||||
patch.object(hardware, "DEVICE_TREE_MODEL", self.dir.name + "/none"),
|
||||
patch.object(hardware, "CPUINFO", cpuinfo),
|
||||
):
|
||||
self.assertEqual(hardware.cpu_model(), "x" * 64)
|
||||
|
||||
def test_collects_gpus_decode_detection_and_storage(self):
|
||||
stats = {
|
||||
"gpu_usages": {
|
||||
"NVIDIA GeForce RTX 3060": {"vendor": "nvidia"},
|
||||
"mystery": {},
|
||||
},
|
||||
"service": {
|
||||
"storage": {
|
||||
RECORD_DIR: {
|
||||
"total": 2048000.0,
|
||||
"used": 512000.0,
|
||||
"mount_type": "nfs4",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
probed = [
|
||||
SimpleNamespace(key="edgetpu:usb", count=2),
|
||||
SimpleNamespace(key="new:thing", count=1),
|
||||
SimpleNamespace(key="cpu", count=1),
|
||||
]
|
||||
families = [
|
||||
SimpleNamespace(key="vaapi"),
|
||||
SimpleNamespace(key="intel-qsv"),
|
||||
SimpleNamespace(key="future"),
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(hardware.hardware_prober, "probe", return_value=probed),
|
||||
patch.object(hardware, "hwaccel_options", return_value=("vaapi", families)),
|
||||
patch.object(hardware, "cpu_model", return_value="Intel(R) N100"),
|
||||
patch("os.cpu_count", return_value=4),
|
||||
patch(
|
||||
"psutil.virtual_memory", return_value=SimpleNamespace(total=16 * 2**30)
|
||||
),
|
||||
):
|
||||
section = hardware.collect(make_context(stats=stats))
|
||||
|
||||
self.assertEqual(section.cpu_model, "Intel(R) N100")
|
||||
self.assertEqual(section.cpu_cores, 4)
|
||||
self.assertEqual(section.memory_gb, 16)
|
||||
self.assertEqual(
|
||||
[(gpu.vendor, gpu.name) for gpu in section.gpus],
|
||||
[
|
||||
(GpuVendor.nvidia, "NVIDIA GeForce RTX 3060"),
|
||||
(GpuVendor.other, "mystery"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
section.decode_families,
|
||||
[DecodeFamily.vaapi, DecodeFamily.intel_qsv, DecodeFamily.other],
|
||||
)
|
||||
self.assertEqual(
|
||||
section.detection_hardware,
|
||||
{HardwareKey.edgetpu_usb: 2, HardwareKey.other: 1, HardwareKey.cpu: 1},
|
||||
)
|
||||
self.assertEqual(section.storage.record_fs, "nfs4")
|
||||
self.assertEqual(section.storage.record_total_gb, 2000)
|
||||
self.assertEqual(section.storage.record_used_pct, 25)
|
||||
|
||||
def test_storage_without_stats_is_unknown_and_empty(self):
|
||||
section = hardware.storage({})
|
||||
|
||||
self.assertEqual(
|
||||
(section.record_fs, section.record_total_gb, section.record_used_pct),
|
||||
("unknown", 0, 0),
|
||||
)
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Tests for building a report from the collectors."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from peewee import SqliteDatabase
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
from frigate.analytics import report
|
||||
from frigate.analytics.collectors import hardware
|
||||
from frigate.analytics.schema import AnalyticsReport, InstallSection
|
||||
from frigate.analytics.state import AnalyticsState
|
||||
from frigate.models import User
|
||||
from frigate.test.analytics_helpers import make_context
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
|
||||
INSTALL = InstallSection(
|
||||
version="0.19.0",
|
||||
image_variant="dev",
|
||||
install_type="docker",
|
||||
arch="x86_64",
|
||||
kernel="6.8",
|
||||
run_as_root=False,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildReport(unittest.TestCase):
|
||||
def test_a_failing_collector_nulls_only_its_section(self):
|
||||
collectors = {
|
||||
"install": lambda ctx: INSTALL,
|
||||
"hardware": Mock(side_effect=RuntimeError("boom")),
|
||||
"detection": lambda ctx: None,
|
||||
"cameras": lambda ctx: None,
|
||||
"features": lambda ctx: None,
|
||||
"health": lambda ctx: None,
|
||||
}
|
||||
|
||||
with patch.dict(report.COLLECTORS, collectors):
|
||||
built = report.build_report(make_context(), "a" * 32, sent_at=1790000000)
|
||||
|
||||
self.assertEqual(built.install, INSTALL)
|
||||
self.assertIsNone(built.hardware)
|
||||
self.assertEqual(built.schema_version, 1)
|
||||
self.assertEqual(built.sent_at, 1790000000)
|
||||
self.assertRegex(built.report_id, r"^[0-9a-f-]{36}$")
|
||||
|
||||
|
||||
class TestRealCollectors(unittest.TestCase):
|
||||
def setUp(self):
|
||||
migrate_db = SqliteDatabase(TEST_DB)
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
Router(migrate_db).run()
|
||||
migrate_db.close()
|
||||
self.db = SqliteQueueDatabase(TEST_DB)
|
||||
self.db.bind([User])
|
||||
|
||||
def tearDown(self):
|
||||
# close() leaves the queue's writer thread running against the deleted file
|
||||
self.db.stop()
|
||||
|
||||
if not self.db.is_closed():
|
||||
self.db.close()
|
||||
|
||||
for file in TEST_DB_CLEANUPS:
|
||||
try:
|
||||
os.remove(file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def test_every_section_builds_and_round_trips_through_json(self):
|
||||
with (
|
||||
patch.object(hardware.hardware_prober, "probe", return_value=[]),
|
||||
patch.object(hardware, "hwaccel_options", return_value=("", [])),
|
||||
):
|
||||
built = report.build_report(make_context(), "a" * 32)
|
||||
|
||||
body = built.model_dump_json()
|
||||
parsed = json.loads(body)
|
||||
|
||||
for section in (
|
||||
"install",
|
||||
"hardware",
|
||||
"detection",
|
||||
"cameras",
|
||||
"features",
|
||||
"health",
|
||||
):
|
||||
with self.subTest(section=section):
|
||||
self.assertIsNotNone(parsed[section])
|
||||
|
||||
self.assertEqual(AnalyticsReport.model_validate_json(body), built)
|
||||
|
||||
def test_preview_uses_a_placeholder_id_before_the_first_report(self):
|
||||
stats_emitter = Mock()
|
||||
stats_emitter.get_latest_stats.return_value = {}
|
||||
|
||||
with (
|
||||
patch.object(report, "load_state", return_value=None),
|
||||
patch.object(hardware.hardware_prober, "probe", return_value=[]),
|
||||
patch.object(hardware, "hwaccel_options", return_value=("", [])),
|
||||
):
|
||||
built = report.preview_report(make_context().config, stats_emitter, None)
|
||||
|
||||
self.assertEqual(built.install_id, report.PREVIEW_INSTALL_ID)
|
||||
|
||||
def test_preview_counts_notices_only_once_opted_in(self):
|
||||
stats_emitter = Mock()
|
||||
stats_emitter.get_latest_stats.return_value = {}
|
||||
registry = Mock()
|
||||
registry.stats.return_value = [
|
||||
{
|
||||
"kind": "detector_stuck",
|
||||
"occurrences": 3,
|
||||
"acknowledgements": 0,
|
||||
"mutes": 0,
|
||||
"reported_occurrences": 1,
|
||||
"reported_acknowledgements": 0,
|
||||
"reported_mutes": 0,
|
||||
}
|
||||
]
|
||||
|
||||
for state, expected in (
|
||||
(None, {}),
|
||||
(AnalyticsState("a" * 32, 0.0), {"detector_stuck": 2}),
|
||||
):
|
||||
with (
|
||||
self.subTest(opted_in=state is not None),
|
||||
patch.object(report, "load_state", return_value=state),
|
||||
patch.object(hardware.hardware_prober, "probe", return_value=[]),
|
||||
patch.object(hardware, "hwaccel_options", return_value=("", [])),
|
||||
):
|
||||
built = report.preview_report(
|
||||
make_context().config, stats_emitter, registry
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{kind: c.occurrences for kind, c in built.health.notices.items()},
|
||||
expected,
|
||||
)
|
||||
@@ -1,349 +0,0 @@
|
||||
"""Tests for the analytics reporter's schedule and consent handling."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from frigate.analytics import reporter as reporter_module
|
||||
from frigate.analytics.reporter import (
|
||||
FIRST_DELAY_S,
|
||||
INTERVAL_S,
|
||||
JITTER_S,
|
||||
PROMPT_KIND,
|
||||
WAKE_S,
|
||||
AnalyticsReporter,
|
||||
)
|
||||
from frigate.analytics.state import AnalyticsState, load_state, save_state
|
||||
from frigate.analytics.transport import SendOutcome
|
||||
from frigate.config.holder import ConfigHolder
|
||||
from frigate.test.analytics_helpers import make_config
|
||||
|
||||
|
||||
class TestAnalyticsReporter(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.dir.cleanup)
|
||||
self.path = os.path.join(self.dir.name, ".analytics.json")
|
||||
self.config_path = os.path.join(self.dir.name, "config.yml")
|
||||
self.save_config("telemetry:\n analytics: true\n")
|
||||
self.now = 1_790_000_000.0
|
||||
self.send = Mock(return_value=SendOutcome.accepted)
|
||||
self.registry = Mock()
|
||||
self.stats = Mock()
|
||||
self.stats.get_latest_stats.return_value = {}
|
||||
self.holder = ConfigHolder(make_config({"telemetry": {"analytics": True}}))
|
||||
|
||||
for name in ("raise_notice", "resolve_notice"):
|
||||
patcher = patch.object(reporter_module, name)
|
||||
setattr(self, name, patcher.start())
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
self.built = Mock()
|
||||
self.built.model_dump_json.return_value = '{"report": 1}'
|
||||
patcher = patch.object(reporter_module, "build_report", return_value=self.built)
|
||||
self.build_report = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def save_config(self, text: str) -> None:
|
||||
with open(self.config_path, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
def seed_state(self, last_attempt_at: float = 0.0) -> None:
|
||||
save_state(AnalyticsState("a" * 32, last_attempt_at), self.path)
|
||||
|
||||
def reporter(self, path: str | None = None) -> AnalyticsReporter:
|
||||
# uniform(a, b) returns a: the shortest startup delay and interval
|
||||
rng = Mock()
|
||||
rng.uniform.side_effect = lambda low, high: low
|
||||
|
||||
return AnalyticsReporter(
|
||||
self.holder,
|
||||
self.stats,
|
||||
self.registry,
|
||||
threading.Event(),
|
||||
state_path=path or self.path,
|
||||
config_path=self.config_path,
|
||||
url="https://example.test/report",
|
||||
send=self.send,
|
||||
clock=lambda: self.now,
|
||||
rng=rng,
|
||||
)
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
|
||||
def test_waits_for_the_startup_delay_then_sends(self):
|
||||
self.seed_state()
|
||||
reporter = self.reporter()
|
||||
|
||||
reporter.tick()
|
||||
self.send.assert_not_called()
|
||||
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_called_once_with(
|
||||
"https://example.test/report", '{"report": 1}'
|
||||
)
|
||||
self.assertEqual(load_state(self.path).last_attempt_at, self.now)
|
||||
|
||||
def test_sends_once_per_interval(self):
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.advance(WAKE_S)
|
||||
reporter.tick()
|
||||
self.assertEqual(self.send.call_count, 1)
|
||||
|
||||
self.advance(INTERVAL_S - JITTER_S)
|
||||
reporter.tick()
|
||||
self.assertEqual(self.send.call_count, 2)
|
||||
|
||||
def test_a_restart_inside_the_interval_does_not_send(self):
|
||||
self.seed_state(self.now)
|
||||
reporter = self.reporter()
|
||||
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_not_called()
|
||||
|
||||
def test_a_failed_send_waits_a_full_interval(self):
|
||||
self.send.return_value = SendOutcome.failed
|
||||
self.seed_state()
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
|
||||
reporter.tick()
|
||||
self.advance(WAKE_S)
|
||||
reporter.tick()
|
||||
|
||||
self.assertEqual(self.send.call_count, 1)
|
||||
|
||||
def assert_reset_before_the_state_file(self) -> None:
|
||||
# the baseline must be in place before the file names the new ID
|
||||
self.registry.reset_analytics_baseline.side_effect = lambda: self.assertIsNone(
|
||||
load_state(self.path)
|
||||
)
|
||||
|
||||
def test_the_opt_in_resets_the_baseline_and_writes_the_identity(self):
|
||||
self.assert_reset_before_the_state_file()
|
||||
reporter = self.reporter()
|
||||
|
||||
# consent is seen on the first wake, well before the first send
|
||||
reporter.tick()
|
||||
self.registry.reset_analytics_baseline.assert_called_once()
|
||||
self.assertIsNotNone(load_state(self.path))
|
||||
self.send.assert_not_called()
|
||||
|
||||
# the first report counts from that reset, so nothing resets again
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_called_once()
|
||||
self.registry.reset_analytics_baseline.assert_called_once()
|
||||
|
||||
def test_a_restart_with_saved_state_keeps_the_baseline(self):
|
||||
self.seed_state()
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_called_once()
|
||||
self.registry.reset_analytics_baseline.assert_not_called()
|
||||
|
||||
def test_a_settings_opt_in_resets_the_baseline_when_saved(self):
|
||||
self.holder.set(make_config())
|
||||
reporter = self.reporter()
|
||||
reporter.tick()
|
||||
|
||||
self.holder.set(make_config({"telemetry": {"analytics": True}}))
|
||||
|
||||
self.registry.reset_analytics_baseline.assert_called_once()
|
||||
self.assertIsNotNone(load_state(self.path))
|
||||
|
||||
def test_a_re_opt_in_during_the_request_keeps_its_new_identity(self):
|
||||
self.seed_state()
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
created = []
|
||||
|
||||
def flip_consent(*args):
|
||||
self.holder.set(make_config())
|
||||
self.holder.set(make_config({"telemetry": {"analytics": True}}))
|
||||
created.append(load_state(self.path))
|
||||
return SendOutcome.accepted
|
||||
|
||||
self.send.side_effect = flip_consent
|
||||
reporter.tick()
|
||||
|
||||
# nothing after the send touches the new identity or its baseline
|
||||
self.assertNotEqual(created[0].install_id, "a" * 32)
|
||||
self.assertEqual(load_state(self.path), created[0])
|
||||
self.registry.reset_analytics_baseline.assert_called_once()
|
||||
|
||||
def test_an_opt_in_without_a_writable_state_file_resets_again_later(self):
|
||||
state_dir = os.path.join(self.dir.name, "missing")
|
||||
reporter = self.reporter(os.path.join(state_dir, ".analytics.json"))
|
||||
|
||||
with self.assertLogs("frigate.analytics.reporter", "WARNING"):
|
||||
reporter.tick()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_not_called()
|
||||
os.mkdir(state_dir)
|
||||
self.registry.reset_analytics_baseline.reset_mock()
|
||||
self.advance(WAKE_S)
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_called_once()
|
||||
self.registry.reset_analytics_baseline.assert_called_once()
|
||||
|
||||
def test_an_opt_out_saved_only_to_the_file_stops_the_send(self):
|
||||
self.seed_state()
|
||||
self.save_config("telemetry:\n analytics: false\n")
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_not_called()
|
||||
self.assertFalse(os.path.exists(self.path))
|
||||
self.raise_notice.assert_not_called()
|
||||
|
||||
def test_reversing_a_file_only_opt_out_resets_the_baseline(self):
|
||||
self.seed_state()
|
||||
self.save_config("telemetry:\n analytics: false\n")
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.save_config("telemetry:\n analytics: true\n")
|
||||
self.assert_reset_before_the_state_file()
|
||||
self.advance(WAKE_S)
|
||||
reporter.tick()
|
||||
|
||||
self.assertNotEqual(load_state(self.path).install_id, "a" * 32)
|
||||
self.registry.reset_analytics_baseline.assert_called_once()
|
||||
self.send.assert_called_once()
|
||||
|
||||
def test_yaml_truthy_spellings_in_the_file_count_as_consent(self):
|
||||
for value in ("yes", "on", "1", "'true'"):
|
||||
with self.subTest(value=value):
|
||||
self.seed_state()
|
||||
self.send.reset_mock()
|
||||
self.save_config(f"telemetry:\n analytics: {value}\n")
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_called_once()
|
||||
self.assertTrue(os.path.exists(self.path))
|
||||
|
||||
def test_an_unreadable_config_file_falls_back_to_the_live_config(self):
|
||||
self.seed_state()
|
||||
self.save_config("telemetry: [unclosed\n")
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_called_once()
|
||||
|
||||
def test_consent_withdrawn_while_the_report_builds_stops_the_send(self):
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
|
||||
def withdraw(*args, **kwargs):
|
||||
self.holder.set(make_config())
|
||||
return self.built
|
||||
|
||||
self.build_report.side_effect = withdraw
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_not_called()
|
||||
self.assertFalse(os.path.exists(self.path))
|
||||
|
||||
def test_turning_sharing_off_and_on_between_wakes_starts_a_fresh_identity(self):
|
||||
self.seed_state()
|
||||
reporter = self.reporter()
|
||||
reporter.tick()
|
||||
|
||||
self.holder.set(make_config())
|
||||
self.holder.set(make_config({"telemetry": {"analytics": True}}))
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.raise_notice.assert_called_once_with(PROMPT_KIND)
|
||||
self.assertNotEqual(load_state(self.path).install_id, "a" * 32)
|
||||
|
||||
def test_opting_out_raises_the_prompt_once_and_deletes_the_state(self):
|
||||
self.holder.set(make_config())
|
||||
self.seed_state()
|
||||
reporter = self.reporter()
|
||||
|
||||
reporter.tick()
|
||||
reporter.tick()
|
||||
|
||||
self.raise_notice.assert_called_once_with(PROMPT_KIND)
|
||||
self.assertFalse(os.path.exists(self.path))
|
||||
self.send.assert_not_called()
|
||||
|
||||
def test_opting_in_resolves_the_prompt(self):
|
||||
self.holder.set(make_config())
|
||||
reporter = self.reporter()
|
||||
reporter.tick()
|
||||
|
||||
self.holder.set(make_config({"telemetry": {"analytics": True}}))
|
||||
reporter.tick()
|
||||
|
||||
self.resolve_notice.assert_called_once_with(PROMPT_KIND)
|
||||
|
||||
def test_safe_mode_touches_nothing(self):
|
||||
self.holder.set(make_config({"safe_mode": True}))
|
||||
self.seed_state()
|
||||
reporter = self.reporter()
|
||||
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
self.assertTrue(os.path.exists(self.path))
|
||||
self.send.assert_not_called()
|
||||
|
||||
def test_an_unwritable_config_dir_skips_sending_and_warns_once(self):
|
||||
reporter = self.reporter(os.path.join(self.dir.name, "missing", "x.json"))
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
|
||||
with self.assertLogs("frigate.analytics.reporter", "WARNING") as logs:
|
||||
reporter.tick()
|
||||
self.advance(WAKE_S)
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_not_called()
|
||||
self.assertEqual(len(logs.output), 1)
|
||||
|
||||
def test_a_last_attempt_in_the_future_is_ignored(self):
|
||||
self.seed_state(self.now + 10 * INTERVAL_S)
|
||||
reporter = self.reporter()
|
||||
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_called_once()
|
||||
|
||||
def test_the_loop_survives_a_failing_tick(self):
|
||||
reporter = self.reporter()
|
||||
reporter.stop_event.set()
|
||||
|
||||
with (
|
||||
patch.object(reporter, "tick", side_effect=RuntimeError("boom")),
|
||||
self.assertLogs("frigate.analytics.reporter", "ERROR"),
|
||||
):
|
||||
reporter.run()
|
||||
@@ -1,137 +0,0 @@
|
||||
"""Tests for the analytics report schema."""
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import unittest
|
||||
from enum import Enum
|
||||
from typing import Any, get_args, get_origin
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
import frigate.detectors.hardware as detection_hardware
|
||||
import frigate.util.hwaccel as hwaccel
|
||||
from frigate.analytics.schema import (
|
||||
AnalyticsReport,
|
||||
DecodeFamily,
|
||||
HardwareKey,
|
||||
HwaccelKey,
|
||||
InputPresetKey,
|
||||
NoticeKindKey,
|
||||
)
|
||||
from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT
|
||||
from frigate.notices.types import NOTICE_KINDS
|
||||
from frigate.util.hwaccel import HwaccelFamily
|
||||
|
||||
# the only str fields: generated ids and strings the hardware reports
|
||||
ALLOWED_STRINGS = {
|
||||
("AnalyticsReport", "install_id"),
|
||||
("AnalyticsReport", "report_id"),
|
||||
("InstallSection", "version"),
|
||||
("InstallSection", "kernel"),
|
||||
("HardwareSection", "cpu_model"),
|
||||
("GpuInfo", "name"),
|
||||
("StorageInfo", "record_fs"),
|
||||
("DetectionModel", "input"),
|
||||
}
|
||||
|
||||
|
||||
def nested_models(annotation: Any) -> list[type[BaseModel]]:
|
||||
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||
return [annotation]
|
||||
|
||||
return [model for arg in get_args(annotation) for model in nested_models(arg)]
|
||||
|
||||
|
||||
def all_models(
|
||||
model: type[BaseModel], seen: set[type[BaseModel]] | None = None
|
||||
) -> list[type[BaseModel]]:
|
||||
seen = set() if seen is None else seen
|
||||
|
||||
if model in seen:
|
||||
return []
|
||||
|
||||
seen.add(model)
|
||||
found = [model]
|
||||
|
||||
for field in model.model_fields.values():
|
||||
for nested in nested_models(field.annotation):
|
||||
found += all_models(nested, seen)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def dict_key_types(annotation: Any) -> list[Any]:
|
||||
keys = [get_args(annotation)[0]] if get_origin(annotation) is dict else []
|
||||
return keys + [key for arg in get_args(annotation) for key in dict_key_types(arg)]
|
||||
|
||||
|
||||
class TestSchemaDiscipline(unittest.TestCase):
|
||||
def test_every_field_has_a_description_and_public_flag(self):
|
||||
for model in all_models(AnalyticsReport):
|
||||
for name, field in model.model_fields.items():
|
||||
with self.subTest(field=f"{model.__name__}.{name}"):
|
||||
self.assertTrue(field.description)
|
||||
self.assertIsInstance(field.json_schema_extra, dict)
|
||||
self.assertIsInstance(field.json_schema_extra.get("x-public"), bool)
|
||||
|
||||
def test_strings_are_limited_to_ids_and_hardware_names(self):
|
||||
for model in all_models(AnalyticsReport):
|
||||
properties = model.model_json_schema()["properties"]
|
||||
|
||||
for name, field in model.model_fields.items():
|
||||
if field.annotation is not str:
|
||||
continue
|
||||
|
||||
with self.subTest(field=f"{model.__name__}.{name}"):
|
||||
self.assertIn((model.__name__, name), ALLOWED_STRINGS)
|
||||
self.assertTrue(
|
||||
"maxLength" in properties[name] or "pattern" in properties[name]
|
||||
)
|
||||
|
||||
def test_map_keys_are_enums(self):
|
||||
for model in all_models(AnalyticsReport):
|
||||
for name, field in model.model_fields.items():
|
||||
for key_type in dict_key_types(field.annotation):
|
||||
with self.subTest(field=f"{model.__name__}.{name}"):
|
||||
self.assertTrue(issubclass(key_type, Enum))
|
||||
|
||||
|
||||
class TestEnumsTrackTheirSources(unittest.TestCase):
|
||||
def test_hardware_keys_cover_every_probe(self):
|
||||
probed = set(
|
||||
re.findall(
|
||||
r'_hardware\(\s*"([^"]+)"', inspect.getsource(detection_hardware)
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(probed)
|
||||
self.assertLessEqual(probed, {key.value for key in HardwareKey})
|
||||
|
||||
def test_decode_families_cover_every_hwaccel_family(self):
|
||||
families = {
|
||||
value.key
|
||||
for value in vars(hwaccel).values()
|
||||
if isinstance(value, HwaccelFamily)
|
||||
}
|
||||
|
||||
self.assertTrue(families)
|
||||
self.assertLessEqual(families, {family.value for family in DecodeFamily})
|
||||
|
||||
def test_preset_keys_mirror_the_presets(self):
|
||||
for enum, presets in (
|
||||
(HwaccelKey, PRESETS_HW_ACCEL_DECODE),
|
||||
(InputPresetKey, PRESETS_INPUT),
|
||||
):
|
||||
with self.subTest(enum=enum.__name__):
|
||||
self.assertEqual(
|
||||
{key.value for key in enum},
|
||||
{name.removeprefix("preset-") for name in presets}
|
||||
| {"custom", "none"},
|
||||
)
|
||||
|
||||
def test_notice_keys_are_the_reportable_kinds(self):
|
||||
self.assertEqual(
|
||||
{key.value for key in NoticeKindKey},
|
||||
{key for key, kind in NOTICE_KINDS.items() if kind.reportable},
|
||||
)
|
||||
self.assertNotIn("analytics_prompt", {key.value for key in NoticeKindKey})
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Tests for the analytics state file."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from frigate.analytics.state import (
|
||||
AnalyticsState,
|
||||
delete_state,
|
||||
load_state,
|
||||
new_state,
|
||||
save_state,
|
||||
)
|
||||
|
||||
|
||||
class TestAnalyticsState(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.dir.cleanup)
|
||||
self.path = os.path.join(self.dir.name, ".analytics.json")
|
||||
|
||||
def write(self, content: str) -> None:
|
||||
with open(self.path, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
def test_missing_file_loads_as_none(self):
|
||||
self.assertIsNone(load_state(self.path))
|
||||
|
||||
def test_save_then_load_round_trips(self):
|
||||
state = AnalyticsState(install_id="a" * 32, last_attempt_at=1790000000.5)
|
||||
|
||||
self.assertTrue(save_state(state, self.path))
|
||||
self.assertEqual(load_state(self.path), state)
|
||||
|
||||
def test_unusable_files_load_as_none(self):
|
||||
for content in (
|
||||
"{not json",
|
||||
'["a"]',
|
||||
'{"install_id": "short", "last_attempt_at": 1}',
|
||||
'{"install_id": "' + "a" * 32 + '"}',
|
||||
'{"install_id": "' + "a" * 32 + '", "last_attempt_at": true}',
|
||||
):
|
||||
with self.subTest(content=content):
|
||||
self.write(content)
|
||||
self.assertIsNone(load_state(self.path))
|
||||
|
||||
def test_save_reports_failure_instead_of_raising(self):
|
||||
missing = os.path.join(self.dir.name, "missing", ".analytics.json")
|
||||
|
||||
self.assertFalse(save_state(new_state(), missing))
|
||||
|
||||
def test_new_state_has_a_hex_id_and_no_attempt(self):
|
||||
state = new_state()
|
||||
|
||||
self.assertRegex(state.install_id, r"^[0-9a-f]{32}$")
|
||||
self.assertEqual(state.last_attempt_at, 0.0)
|
||||
|
||||
def test_delete_removes_the_file_and_tolerates_a_missing_one(self):
|
||||
save_state(new_state(), self.path)
|
||||
|
||||
delete_state(self.path)
|
||||
delete_state(self.path)
|
||||
|
||||
self.assertFalse(os.path.exists(self.path))
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Tests for sending a report."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from frigate.analytics.transport import TIMEOUT_S, SendOutcome, send_report
|
||||
from frigate.version import VERSION
|
||||
|
||||
URL = "https://example.test/report"
|
||||
|
||||
|
||||
def post(status: int = 204, text: str = "", side_effect: Exception | None = None):
|
||||
return patch(
|
||||
"frigate.analytics.transport.requests.post",
|
||||
return_value=Mock(status_code=status, text=text),
|
||||
side_effect=side_effect,
|
||||
)
|
||||
|
||||
|
||||
class TestSendReport(unittest.TestCase):
|
||||
def test_posts_the_body_as_json_with_a_user_agent(self):
|
||||
with post() as mock_post:
|
||||
outcome = send_report(URL, '{"a":1}')
|
||||
|
||||
self.assertIs(outcome, SendOutcome.accepted)
|
||||
args, kwargs = mock_post.call_args
|
||||
self.assertEqual(args[0], URL)
|
||||
self.assertEqual(kwargs["data"], b'{"a":1}')
|
||||
self.assertEqual(kwargs["headers"]["Content-Type"], "application/json")
|
||||
self.assertEqual(kwargs["headers"]["User-Agent"], f"Frigate/{VERSION}")
|
||||
self.assertFalse(kwargs["allow_redirects"])
|
||||
self.assertEqual(kwargs["timeout"], TIMEOUT_S)
|
||||
|
||||
def test_maps_statuses_to_outcomes(self):
|
||||
cases = (
|
||||
(200, SendOutcome.accepted),
|
||||
(400, SendOutcome.rejected),
|
||||
(429, SendOutcome.rate_limited),
|
||||
(302, SendOutcome.failed),
|
||||
(500, SendOutcome.failed),
|
||||
)
|
||||
|
||||
for status, outcome in cases:
|
||||
with self.subTest(status=status), post(status):
|
||||
self.assertIs(send_report(URL, "{}"), outcome)
|
||||
|
||||
def test_logs_the_rejection_reason(self):
|
||||
with (
|
||||
post(400, "install_id: bad"),
|
||||
self.assertLogs("frigate.analytics.transport", "DEBUG") as logs,
|
||||
):
|
||||
send_report(URL, "{}")
|
||||
|
||||
self.assertIn("install_id: bad", logs.output[0])
|
||||
|
||||
def test_a_network_error_fails_without_raising(self):
|
||||
# the user can't act on a failed send, so it stays out of warnings
|
||||
with (
|
||||
post(side_effect=requests.ConnectionError("down")),
|
||||
self.assertNoLogs("frigate.analytics.transport", "WARNING"),
|
||||
):
|
||||
self.assertIs(send_report(URL, "{}"), SendOutcome.failed)
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Tests for frigate.util.builtin helpers."""
|
||||
|
||||
import unittest
|
||||
from multiprocessing import Value
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||
from frigate.util.builtin import EventsPerSecond
|
||||
|
||||
|
||||
class TestEventsPerSecond(unittest.TestCase):
|
||||
@@ -63,17 +62,5 @@ class TestEventsPerSecond(unittest.TestCase):
|
||||
self.assertAlmostEqual(eps.eps(), 20.0)
|
||||
|
||||
|
||||
class TestInferenceSpeed(unittest.TestCase):
|
||||
def test_first_inference_seeds_the_average(self) -> None:
|
||||
metric = Value("d", 0.0)
|
||||
speed = InferenceSpeed(metric)
|
||||
|
||||
speed.update(0.02)
|
||||
self.assertEqual(metric.value, 0.02)
|
||||
|
||||
speed.update(0.12)
|
||||
self.assertAlmostEqual(metric.value, 0.03)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -96,52 +96,6 @@ class TestCameraWatchdogReset(unittest.TestCase):
|
||||
watchdog.start_ffmpeg_detect.assert_called_once()
|
||||
|
||||
|
||||
class TestCameraWatchdogStop(unittest.TestCase):
|
||||
def _build_stopping_watchdog(self, capture_reading: bool) -> CameraWatchdog:
|
||||
watchdog = build_watchdog()
|
||||
watchdog.ffmpeg_detect_process = MagicMock()
|
||||
watchdog.capture_thread = MagicMock()
|
||||
watchdog.capture_thread.is_alive.return_value = capture_reading
|
||||
return watchdog
|
||||
|
||||
def test_live_capture_thread_terminates_without_reading_stdout(self):
|
||||
watchdog = self._build_stopping_watchdog(capture_reading=True)
|
||||
process = watchdog.ffmpeg_detect_process
|
||||
|
||||
with patch("frigate.video.ffmpeg.stop_ffmpeg") as stop_ffmpeg:
|
||||
watchdog.stop_all_ffmpeg()
|
||||
|
||||
process.terminate.assert_called_once()
|
||||
process.wait.assert_called_once_with(timeout=30)
|
||||
process.kill.assert_not_called()
|
||||
process.communicate.assert_not_called()
|
||||
stop_ffmpeg.assert_not_called()
|
||||
self.assertIsNone(watchdog.ffmpeg_detect_process)
|
||||
|
||||
def test_live_capture_thread_kills_ffmpeg_that_ignores_sigterm(self):
|
||||
watchdog = self._build_stopping_watchdog(capture_reading=True)
|
||||
process = watchdog.ffmpeg_detect_process
|
||||
process.wait.side_effect = [sp.TimeoutExpired("ffmpeg", 30), 0]
|
||||
|
||||
watchdog.stop_all_ffmpeg()
|
||||
|
||||
process.kill.assert_called_once()
|
||||
self.assertEqual(process.wait.call_args_list, [call(timeout=30), call()])
|
||||
process.communicate.assert_not_called()
|
||||
|
||||
def test_joined_capture_thread_uses_stop_ffmpeg(self):
|
||||
watchdog = self._build_stopping_watchdog(capture_reading=False)
|
||||
process = watchdog.ffmpeg_detect_process
|
||||
watchdog.capture_thread.is_alive.side_effect = [True, False]
|
||||
|
||||
with patch("frigate.video.ffmpeg.stop_ffmpeg") as stop_ffmpeg:
|
||||
watchdog.stop_all_ffmpeg()
|
||||
|
||||
stop_ffmpeg.assert_called_once_with(process, watchdog.logger)
|
||||
process.terminate.assert_not_called()
|
||||
self.assertIsNone(watchdog.ffmpeg_detect_process)
|
||||
|
||||
|
||||
class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
||||
def _build_watchdog(
|
||||
self, sub_enabled: bool = True, output_args: dict | None = None
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Tests for camera watch ownership in the chat tools."""
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from frigate.api.chat import _execute_start_camera_watch, _execute_stop_camera_watch
|
||||
from frigate.jobs.vlm_watch import VLMWatchJob
|
||||
|
||||
|
||||
def _request(user: str, role: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(headers={"remote-user": user, "remote-role": role})
|
||||
|
||||
|
||||
class TestStopCameraWatch(unittest.TestCase):
|
||||
def setUp(self):
|
||||
job = VLMWatchJob(camera="front", condition="person", username="alice")
|
||||
patch("frigate.api.chat.get_vlm_watch_job", return_value=job).start()
|
||||
self.stop = patch(
|
||||
"frigate.api.chat.stop_vlm_watch_job", return_value=True
|
||||
).start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def test_other_user_cannot_cancel(self):
|
||||
result = _execute_stop_camera_watch(_request("bob", "viewer"))
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.stop.assert_not_called()
|
||||
|
||||
def test_creator_can_cancel(self):
|
||||
result = _execute_stop_camera_watch(_request("alice", "viewer"))
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.stop.assert_called_once()
|
||||
|
||||
def test_admin_can_cancel(self):
|
||||
result = _execute_stop_camera_watch(_request("bob", "admin"))
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.stop.assert_called_once()
|
||||
|
||||
|
||||
class TestStartCameraWatch(unittest.TestCase):
|
||||
@patch("frigate.api.chat.require_camera_access", new_callable=AsyncMock)
|
||||
@patch("frigate.api.chat.start_vlm_watch_job", return_value="job1")
|
||||
def test_records_caller_as_owner(self, start, _access):
|
||||
request = _request("alice", "viewer")
|
||||
request.app = MagicMock()
|
||||
request.app.frigate_config.cameras = {"front": None}
|
||||
request.app.genai_manager.chat_client.supports_vision = True
|
||||
|
||||
asyncio.run(
|
||||
_execute_start_camera_watch(
|
||||
request, {"camera": "front", "condition": "person"}
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(start.call_args.kwargs["username"], "alice")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -15,15 +15,9 @@ from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.detectors import DetectorTypeEnum
|
||||
from frigate.detectors.device import build_detector_config, runner_names
|
||||
from frigate.util.builtin import deep_merge
|
||||
from frigate.util.config import resolve_ffmpeg_path
|
||||
|
||||
|
||||
class TestConfig(unittest.TestCase):
|
||||
def test_analytics_is_off_by_default(self):
|
||||
frigate_config = FrigateConfig(**self.minimal)
|
||||
|
||||
self.assertFalse(frigate_config.telemetry.analytics)
|
||||
|
||||
def setUp(self):
|
||||
self.minimal = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
@@ -2378,54 +2372,5 @@ class TestAttributeFilterDefaults(unittest.TestCase):
|
||||
self.assertEqual(face_filter.min_score, 0.3)
|
||||
|
||||
|
||||
class TestFfmpegPath(unittest.TestCase):
|
||||
# ffmpeg commands are split on spaces, so this would run /bin/sh
|
||||
UNSAFE_PATH = "/bin/sh -c id;#"
|
||||
|
||||
def setUp(self):
|
||||
self.minimal = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def test_custom_path_allowed(self):
|
||||
config = deep_merge(
|
||||
{"ffmpeg": {"path": "/usr/lib/jellyfin-ffmpeg"}}, self.minimal
|
||||
)
|
||||
frigate_config = FrigateConfig(**config)
|
||||
self.assertEqual(
|
||||
frigate_config.cameras["back"].ffmpeg.ffmpeg_path,
|
||||
"/usr/lib/jellyfin-ffmpeg/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_unsafe_global_path_rejected(self):
|
||||
for path in (self.UNSAFE_PATH, "/opt/my ffmpeg", "/opt/'x'", "/opt/x\n"):
|
||||
config = deep_merge({"ffmpeg": {"path": path}}, self.minimal)
|
||||
with self.assertRaises(ValidationError, msg=path):
|
||||
FrigateConfig(**config)
|
||||
|
||||
def test_unsafe_camera_path_rejected(self):
|
||||
config = deep_merge(
|
||||
{"cameras": {"back": {"ffmpeg": {"path": self.UNSAFE_PATH}}}},
|
||||
self.minimal,
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
FrigateConfig(**config)
|
||||
|
||||
def test_resolve_falls_back_for_unsafe_path(self):
|
||||
self.assertEqual(
|
||||
resolve_ffmpeg_path(self.UNSAFE_PATH), resolve_ffmpeg_path("default")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -83,30 +83,5 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
||||
app.genai_manager.update_config.assert_called_once_with(config)
|
||||
|
||||
|
||||
class TestConfigHolder(unittest.TestCase):
|
||||
def test_set_passes_the_new_config_to_subscribers(self) -> None:
|
||||
holder = ConfigHolder(MagicMock(name="boot_config"))
|
||||
listener = MagicMock()
|
||||
holder.subscribe(listener)
|
||||
config = MagicMock(name="new_config")
|
||||
|
||||
holder.set(config)
|
||||
|
||||
listener.assert_called_once_with(config)
|
||||
|
||||
def test_a_failing_subscriber_does_not_block_the_swap(self) -> None:
|
||||
holder = ConfigHolder(MagicMock(name="boot_config"))
|
||||
after = MagicMock()
|
||||
holder.subscribe(MagicMock(side_effect=RuntimeError("boom")))
|
||||
holder.subscribe(after)
|
||||
config = MagicMock(name="new_config")
|
||||
|
||||
with self.assertLogs("frigate.config.holder", "ERROR"):
|
||||
holder.set(config)
|
||||
|
||||
self.assertIs(holder.config, config)
|
||||
after.assert_called_once_with(config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"""Tests for custom ffmpeg path resolution and the root-mode guard."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.const import DEFAULT_FFMPEG_VERSION
|
||||
from frigate.util.config import (
|
||||
_warn_ignored_ffmpeg_path,
|
||||
frigate_service_is_granular_root,
|
||||
resolve_ffmpeg_path,
|
||||
service_is_granular_root,
|
||||
)
|
||||
|
||||
BUNDLED = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
|
||||
@@ -23,20 +21,12 @@ class TestConfigFfmpegRootGuard(unittest.TestCase):
|
||||
# the warning is memoized so it fires once per path, not per camera
|
||||
_warn_ignored_ffmpeg_path.cache_clear()
|
||||
|
||||
def _resolve(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
euid: int,
|
||||
env: dict,
|
||||
binary: str = "ffmpeg",
|
||||
service: str = "frigate",
|
||||
):
|
||||
def _resolve(self, path: str, *, euid: int, env: dict, binary: str = "ffmpeg"):
|
||||
with (
|
||||
patch("os.geteuid", return_value=euid),
|
||||
patch.dict("os.environ", env, clear=True),
|
||||
):
|
||||
return resolve_ffmpeg_path(path, binary, service)
|
||||
return resolve_ffmpeg_path(path, binary)
|
||||
|
||||
def test_custom_path_used_when_service_is_unprivileged(self) -> None:
|
||||
self.assertEqual(
|
||||
@@ -75,59 +65,6 @@ class TestConfigFfmpegRootGuard(unittest.TestCase):
|
||||
f"{CUSTOM}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_custom_path_ignored_for_go2rtc_when_go2rtc_is_a_root_service(
|
||||
self,
|
||||
) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve(
|
||||
CUSTOM,
|
||||
euid=0,
|
||||
env={"FRIGATE_ROOT_SERVICES": "go2rtc"},
|
||||
service="go2rtc",
|
||||
),
|
||||
BUNDLED,
|
||||
)
|
||||
|
||||
def test_go2rtc_keeps_custom_path_when_only_frigate_is_root(self) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve(
|
||||
CUSTOM,
|
||||
euid=0,
|
||||
env={"FRIGATE_ROOT_SERVICES": "frigate"},
|
||||
service="go2rtc",
|
||||
),
|
||||
f"{CUSTOM}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_go2rtc_custom_path_used_in_default_drop_mode(self) -> None:
|
||||
# the run scripts resolve as root but probe as the service user
|
||||
self.assertEqual(
|
||||
self._resolve(CUSTOM, euid=0, env={}, service="go2rtc"),
|
||||
f"{CUSTOM}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_escape_hatch_keeps_go2rtc_custom_path(self) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve(
|
||||
CUSTOM,
|
||||
euid=0,
|
||||
env={"FRIGATE_RUN_AS_ROOT": "true", "FRIGATE_ROOT_SERVICES": "go2rtc"},
|
||||
service="go2rtc",
|
||||
),
|
||||
f"{CUSTOM}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_go2rtc_root_owned_path_is_left_alone(self) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve(
|
||||
"/opt/custom-ffmpeg",
|
||||
euid=0,
|
||||
env={"FRIGATE_ROOT_SERVICES": "go2rtc"},
|
||||
service="go2rtc",
|
||||
),
|
||||
"/opt/custom-ffmpeg/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_root_services_has_no_effect_under_docker_user(self) -> None:
|
||||
# docker's own user: means the service never had root to keep, which is
|
||||
# also the case for get_ffmpeg_path.py in a --user container
|
||||
@@ -178,65 +115,15 @@ class TestConfigFfmpegRootGuard(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestConfigFfmpegRootGuardSymlinks(unittest.TestCase):
|
||||
"""A root service gets the resolved path, so a link cannot be retargeted."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
_warn_ignored_ffmpeg_path.cache_clear()
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
root = os.path.realpath(tmp.name)
|
||||
self.writable = os.path.join(root, "config")
|
||||
self.root_owned = os.path.join(root, "opt", "ffmpeg")
|
||||
os.makedirs(self.writable)
|
||||
os.makedirs(self.root_owned)
|
||||
self.link = os.path.join(self.writable, "link")
|
||||
dirs = patch("frigate.util.config.RUNTIME_USER_WRITABLE_DIRS", (self.writable,))
|
||||
dirs.start()
|
||||
self.addCleanup(dirs.stop)
|
||||
|
||||
def _resolve(self, env: dict, service: str = "frigate") -> str:
|
||||
with (
|
||||
patch("os.geteuid", return_value=0),
|
||||
patch.dict("os.environ", env, clear=True),
|
||||
):
|
||||
return resolve_ffmpeg_path(self.link, "ffmpeg", service)
|
||||
|
||||
def test_link_to_a_root_owned_tree_resolves_to_its_target(self) -> None:
|
||||
os.symlink(self.root_owned, self.link)
|
||||
for service in ("frigate", "go2rtc"):
|
||||
self.assertEqual(
|
||||
self._resolve({"FRIGATE_ROOT_SERVICES": service}, service),
|
||||
f"{self.root_owned}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_link_to_a_writable_tree_is_refused(self) -> None:
|
||||
evil = os.path.join(self.writable, "evil")
|
||||
os.makedirs(evil)
|
||||
os.symlink(evil, self.link)
|
||||
self.assertEqual(self._resolve({"FRIGATE_ROOT_SERVICES": "frigate"}), BUNDLED)
|
||||
|
||||
def test_bin_linked_into_a_writable_tree_is_refused(self) -> None:
|
||||
evil = os.path.join(self.writable, "evil")
|
||||
os.makedirs(evil)
|
||||
os.symlink(evil, os.path.join(self.root_owned, "bin"))
|
||||
os.symlink(self.root_owned, self.link)
|
||||
self.assertEqual(self._resolve({"FRIGATE_ROOT_SERVICES": "frigate"}), BUNDLED)
|
||||
|
||||
def test_unprivileged_service_keeps_the_path_as_written(self) -> None:
|
||||
os.symlink(self.root_owned, self.link)
|
||||
self.assertEqual(self._resolve({}), f"{self.link}/bin/ffmpeg")
|
||||
|
||||
|
||||
class TestServiceIsGranularRoot(unittest.TestCase):
|
||||
class TestFrigateServiceIsGranularRoot(unittest.TestCase):
|
||||
"""Root via FRIGATE_ROOT_SERVICES only, never via the escape hatch."""
|
||||
|
||||
def _check(self, *, euid: int, env: dict, service: str = "frigate") -> bool:
|
||||
def _check(self, *, euid: int, env: dict) -> bool:
|
||||
with (
|
||||
patch("os.geteuid", return_value=euid),
|
||||
patch.dict("os.environ", env, clear=True),
|
||||
):
|
||||
return service_is_granular_root(service)
|
||||
return frigate_service_is_granular_root()
|
||||
|
||||
def test_false_without_any_root_signal(self) -> None:
|
||||
self.assertFalse(self._check(euid=0, env={}))
|
||||
@@ -259,12 +146,6 @@ class TestServiceIsGranularRoot(unittest.TestCase):
|
||||
self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "go2rtc,nginx"})
|
||||
)
|
||||
|
||||
def test_matches_the_named_service(self) -> None:
|
||||
env = {"FRIGATE_ROOT_SERVICES": "go2rtc"}
|
||||
self.assertTrue(self._check(euid=0, env=env, service="go2rtc"))
|
||||
self.assertFalse(self._check(euid=0, env=env, service="frigate"))
|
||||
self.assertFalse(self._check(euid=1000, env=env, service="go2rtc"))
|
||||
|
||||
def test_substring_of_a_service_name_does_not_match(self) -> None:
|
||||
self.assertFalse(self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "frigatee"}))
|
||||
|
||||
|
||||
@@ -145,72 +145,12 @@ def _openai_chunk(content=None, tool_calls=None, finish_reason=None, usage=None)
|
||||
return SimpleNamespace(choices=[choice], usage=usage)
|
||||
|
||||
|
||||
class _FakeAsyncOpenAIStream:
|
||||
"""Mimics openai.AsyncStream: async iterable and async context manager.
|
||||
|
||||
With ``stall_after`` set, the stream stops producing after that many
|
||||
chunks, like a model that stops sending tokens, and sets ``stalled``.
|
||||
"""
|
||||
|
||||
def __init__(self, chunks, stall_after=None):
|
||||
self._chunks = chunks
|
||||
self._stall_after = stall_after
|
||||
self.stalled = asyncio.Event()
|
||||
self.close = AsyncMock()
|
||||
|
||||
async def __aiter__(self):
|
||||
for i, chunk in enumerate(self._chunks):
|
||||
if i == self._stall_after:
|
||||
self.stalled.set()
|
||||
await asyncio.Event().wait()
|
||||
yield chunk
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
await self.close()
|
||||
|
||||
|
||||
class _FakeAsyncOpenAI:
|
||||
"""Mimics openai.AsyncOpenAI: async context manager with a completions API."""
|
||||
|
||||
def __init__(self, create):
|
||||
self.chat = SimpleNamespace(completions=SimpleNamespace(create=create))
|
||||
self.close = AsyncMock()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
await self.close()
|
||||
|
||||
|
||||
class TestOpenAIProvider(unittest.TestCase):
|
||||
def _client(self):
|
||||
return _make_client(
|
||||
"openai", model="gpt-4o", api_key="k", base_url="http://localhost:9999/v1"
|
||||
)
|
||||
|
||||
def _streaming_client(self, create):
|
||||
"""Build a client whose async completions call is ``create``."""
|
||||
client = self._client()
|
||||
async_client = _FakeAsyncOpenAI(create)
|
||||
client._init_async_provider = MagicMock(return_value=async_client)
|
||||
return client, async_client
|
||||
|
||||
def test_async_client_uses_configured_options(self):
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
client = self._client()
|
||||
async_client = client._init_async_provider()
|
||||
try:
|
||||
self.assertIsInstance(async_client, AsyncOpenAI)
|
||||
self.assertEqual(async_client.base_url, client.provider.base_url)
|
||||
self.assertEqual(async_client.api_key, "k")
|
||||
finally:
|
||||
asyncio.run(async_client.close())
|
||||
|
||||
def test_stream_tool_call_arguments_are_dict(self):
|
||||
# Arguments arrive split across chunks, as the real API streams them.
|
||||
chunks = [
|
||||
@@ -222,9 +162,8 @@ class TestOpenAIProvider(unittest.TestCase):
|
||||
_openai_chunk(tool_calls=[_openai_tc(0, arguments=' "person"}')]),
|
||||
_openai_chunk(finish_reason="tool_calls"),
|
||||
]
|
||||
client, _ = self._streaming_client(
|
||||
AsyncMock(return_value=_FakeAsyncOpenAIStream(chunks))
|
||||
)
|
||||
client = self._client()
|
||||
client.provider.chat.completions.create = MagicMock(return_value=iter(chunks))
|
||||
|
||||
final = _final_message(_collect(client, SIMPLE_MESSAGES))
|
||||
self.assertEqual(final["finish_reason"], "tool_calls")
|
||||
@@ -238,9 +177,8 @@ class TestOpenAIProvider(unittest.TestCase):
|
||||
_openai_chunk(content="lo"),
|
||||
_openai_chunk(finish_reason="stop"),
|
||||
]
|
||||
client, _ = self._streaming_client(
|
||||
AsyncMock(return_value=_FakeAsyncOpenAIStream(chunks))
|
||||
)
|
||||
client = self._client()
|
||||
client.provider.chat.completions.create = MagicMock(return_value=iter(chunks))
|
||||
|
||||
events = _collect(client, SIMPLE_MESSAGES)
|
||||
deltas = [v for (k, v) in events if k == "content_delta"]
|
||||
@@ -248,84 +186,14 @@ class TestOpenAIProvider(unittest.TestCase):
|
||||
self.assertEqual(_final_message(events)["content"], "hello")
|
||||
|
||||
def test_multimodal_message_does_not_crash(self):
|
||||
client, _ = self._streaming_client(
|
||||
AsyncMock(
|
||||
return_value=_FakeAsyncOpenAIStream(
|
||||
[_openai_chunk(content="ok", finish_reason="stop")]
|
||||
)
|
||||
)
|
||||
client = self._client()
|
||||
client.provider.chat.completions.create = MagicMock(
|
||||
return_value=iter([_openai_chunk(content="ok", finish_reason="stop")])
|
||||
)
|
||||
# Passing the OpenAI-native multimodal list through must not raise.
|
||||
final = _final_message(_collect(client, MULTIMODAL_MESSAGES))
|
||||
self.assertEqual(final["content"], "ok")
|
||||
|
||||
def test_stream_uses_async_client_and_closes_it(self):
|
||||
# The blocking sync client must never be used on the event loop.
|
||||
stream = _FakeAsyncOpenAIStream(
|
||||
[_openai_chunk(content="ok", finish_reason="stop")]
|
||||
)
|
||||
create = AsyncMock(return_value=stream)
|
||||
client, async_client = self._streaming_client(create)
|
||||
client.provider.chat.completions.create = MagicMock()
|
||||
|
||||
final = _final_message(_collect(client, SIMPLE_MESSAGES))
|
||||
|
||||
self.assertEqual(final["content"], "ok")
|
||||
self.assertTrue(create.await_args.kwargs["stream"])
|
||||
client.provider.chat.completions.create.assert_not_called()
|
||||
stream.close.assert_awaited_once()
|
||||
async_client.close.assert_awaited_once()
|
||||
|
||||
def test_client_closed_when_caller_leaves_while_opening(self):
|
||||
# Cancelling before the response arrives must still close the client.
|
||||
async def _run():
|
||||
opening = asyncio.Event()
|
||||
|
||||
async def hang(**kwargs):
|
||||
opening.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
client, async_client = self._streaming_client(hang)
|
||||
gen = client.chat_with_tools_stream(
|
||||
messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto"
|
||||
)
|
||||
task = asyncio.create_task(gen.__anext__())
|
||||
await opening.wait()
|
||||
task.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await asyncio.wait_for(task, timeout=1)
|
||||
return async_client
|
||||
|
||||
async_client = asyncio.run(_run())
|
||||
async_client.close.assert_awaited_once()
|
||||
|
||||
def test_stream_closed_when_caller_leaves_mid_stream(self):
|
||||
# Cancelling while waiting on a stalled model must close the stream
|
||||
# right away instead of waiting for the next chunk.
|
||||
async def _run():
|
||||
stream = _FakeAsyncOpenAIStream(
|
||||
[_openai_chunk(content="a"), _openai_chunk(finish_reason="stop")],
|
||||
stall_after=1,
|
||||
)
|
||||
client, async_client = self._streaming_client(
|
||||
AsyncMock(return_value=stream)
|
||||
)
|
||||
gen = client.chat_with_tools_stream(
|
||||
messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto"
|
||||
)
|
||||
first = await gen.__anext__()
|
||||
task = asyncio.create_task(gen.__anext__())
|
||||
await stream.stalled.wait()
|
||||
task.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await asyncio.wait_for(task, timeout=1)
|
||||
return first, stream, async_client
|
||||
|
||||
first, stream, async_client = asyncio.run(_run())
|
||||
self.assertEqual(first, ("content_delta", "a"))
|
||||
stream.close.assert_awaited_once()
|
||||
async_client.close.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gemini
|
||||
@@ -431,56 +299,6 @@ class TestGeminiProvider(unittest.TestCase):
|
||||
self.assertEqual(final["content"], "ok")
|
||||
|
||||
|
||||
class TestGeminiImageTokens(unittest.TestCase):
|
||||
def _client(self, count_tokens):
|
||||
client = _make_client("gemini", model="gemini-3-flash", api_key="k")
|
||||
client.provider = SimpleNamespace(
|
||||
models=SimpleNamespace(count_tokens=count_tokens)
|
||||
)
|
||||
return client
|
||||
|
||||
def test_measures_image_cost_and_caches_per_size(self):
|
||||
count_tokens = MagicMock(
|
||||
side_effect=lambda model, contents, config: SimpleNamespace(
|
||||
total_tokens=2 if len(contents) == 1 else 1102
|
||||
)
|
||||
)
|
||||
client = self._client(count_tokens)
|
||||
|
||||
self.assertEqual(client.estimate_image_tokens(320, 180), 1100)
|
||||
self.assertEqual(client.estimate_image_tokens(320, 180), 1100)
|
||||
self.assertEqual(count_tokens.call_count, 2)
|
||||
|
||||
image = count_tokens.call_args.kwargs["contents"][1]
|
||||
self.assertEqual(image.inline_data.mime_type, "image/jpeg")
|
||||
self.assertEqual(count_tokens.call_args.kwargs["model"], "gemini-3-flash")
|
||||
http_options = count_tokens.call_args.kwargs["config"].http_options
|
||||
self.assertEqual(http_options.timeout, 5000)
|
||||
self.assertEqual(http_options.retry_options.attempts, 1)
|
||||
|
||||
client.estimate_image_tokens(640, 360)
|
||||
self.assertEqual(count_tokens.call_count, 4)
|
||||
|
||||
def test_failure_falls_back_to_heuristic_and_waits_before_retrying(self):
|
||||
count_tokens = MagicMock(side_effect=RuntimeError("offline"))
|
||||
client = self._client(count_tokens)
|
||||
|
||||
with patch("frigate.genai.plugins.gemini.time.monotonic", return_value=1000):
|
||||
self.assertEqual(client.estimate_image_tokens(320, 180), 320 * 180 / 1250)
|
||||
self.assertEqual(client.estimate_image_tokens(640, 360), 640 * 360 / 1250)
|
||||
self.assertEqual(count_tokens.call_count, 1)
|
||||
|
||||
with patch("frigate.genai.plugins.gemini.time.monotonic", return_value=1300):
|
||||
client.estimate_image_tokens(320, 180)
|
||||
self.assertEqual(count_tokens.call_count, 2)
|
||||
|
||||
def test_missing_total_falls_back_to_heuristic(self):
|
||||
client = self._client(
|
||||
MagicMock(return_value=SimpleNamespace(total_tokens=None))
|
||||
)
|
||||
self.assertEqual(client.estimate_image_tokens(320, 180), 320 * 180 / 1250)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ollama
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1122,17 +940,6 @@ class TestAzureOpenAITranscribe(unittest.TestCase):
|
||||
self.assertIsInstance(client.provider, AzureOpenAI)
|
||||
self.assertTrue(client.supports_transcription)
|
||||
|
||||
def test_streaming_routes_through_async_azure_client(self):
|
||||
from openai import AsyncAzureOpenAI
|
||||
|
||||
client = self._client()
|
||||
async_client = client._init_async_provider()
|
||||
try:
|
||||
self.assertIsInstance(async_client, AsyncAzureOpenAI)
|
||||
self.assertEqual(async_client.base_url, client.provider.base_url)
|
||||
finally:
|
||||
asyncio.run(async_client.close())
|
||||
|
||||
def test_transcribe_inherited(self):
|
||||
client = self._client()
|
||||
create = MagicMock(return_value="azure text")
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Tests for the go2rtc ffmpeg and ngrok options applied at startup."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.util.services import (
|
||||
apply_go2rtc_ffmpeg_config,
|
||||
is_restricted_go2rtc_api_source,
|
||||
is_restricted_go2rtc_source,
|
||||
)
|
||||
|
||||
FFMPEG_PATH = "/usr/lib/ffmpeg/7.0/bin/ffmpeg"
|
||||
|
||||
|
||||
class TestApplyGo2rtcFfmpegConfig(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# the override is snapshotted from the environment at import time
|
||||
exec_patch = patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", None)
|
||||
exec_patch.start()
|
||||
self.addCleanup(exec_patch.stop)
|
||||
|
||||
def test_sets_bin_when_missing(self):
|
||||
config = {}
|
||||
assert apply_go2rtc_ffmpeg_config(config, FFMPEG_PATH) == []
|
||||
assert config["ffmpeg"] == {"bin": FFMPEG_PATH}
|
||||
|
||||
def test_forces_bin_override(self):
|
||||
config = {"ffmpeg": {"bin": "/bin/sh", "global": "-c 'id' x"}}
|
||||
assert apply_go2rtc_ffmpeg_config(config, FFMPEG_PATH) == ["ffmpeg.bin"]
|
||||
assert config["ffmpeg"]["bin"] == FFMPEG_PATH
|
||||
|
||||
def test_keeps_saved_templates(self):
|
||||
config = {"ffmpeg": {"global": "-vaapi_device /dev/dri/renderD128"}}
|
||||
assert apply_go2rtc_ffmpeg_config(config, FFMPEG_PATH) == []
|
||||
assert config["ffmpeg"]["global"] == "-vaapi_device /dev/dri/renderD128"
|
||||
|
||||
def test_drops_ngrok(self):
|
||||
config = {"ngrok": {"command": "/bin/sh -c id"}}
|
||||
assert apply_go2rtc_ffmpeg_config(config, FFMPEG_PATH) == ["ngrok"]
|
||||
assert "ngrok" not in config
|
||||
|
||||
def test_allows_bin_and_ngrok_when_override_set(self):
|
||||
config = {
|
||||
"ffmpeg": {"bin": "/usr/bin/ffmpeg"},
|
||||
"ngrok": {"command": "ngrok tcp 8555"},
|
||||
}
|
||||
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
|
||||
assert apply_go2rtc_ffmpeg_config(config, FFMPEG_PATH) == []
|
||||
assert config["ffmpeg"]["bin"] == "/usr/bin/ffmpeg"
|
||||
assert config["ngrok"] == {"command": "ngrok tcp 8555"}
|
||||
|
||||
|
||||
class TestRestrictedGo2rtcVarExpansion(unittest.TestCase):
|
||||
"""go2rtc expands `${VAR:default}` over the raw config before parsing, and
|
||||
the default can name an exec source, so a `${` source is restricted."""
|
||||
|
||||
def setUp(self):
|
||||
exec_patch = patch(
|
||||
"frigate.util.services.is_go2rtc_arbitrary_exec_allowed",
|
||||
return_value=False,
|
||||
)
|
||||
self.mock_exec_allowed = exec_patch.start()
|
||||
self.addCleanup(exec_patch.stop)
|
||||
|
||||
def test_var_expansion_source_is_restricted(self):
|
||||
for source in (
|
||||
"${NOPE:exec:touch /tmp/x}",
|
||||
"rtsp://host/${CAM}",
|
||||
" ${NOPE:exec:id}",
|
||||
):
|
||||
self.assertTrue(is_restricted_go2rtc_source(source), source)
|
||||
self.assertTrue(is_restricted_go2rtc_api_source(source), source)
|
||||
|
||||
def test_plain_sources_are_not_restricted(self):
|
||||
for source in (
|
||||
"rtsp://user:pass@host/stream",
|
||||
"ffmpeg:front#video=h264#height=720",
|
||||
):
|
||||
self.assertFalse(is_restricted_go2rtc_source(source), source)
|
||||
self.assertFalse(is_restricted_go2rtc_api_source(source), source)
|
||||
|
||||
def test_var_expansion_allowed_when_override_set(self):
|
||||
self.mock_exec_allowed.return_value = True
|
||||
self.assertFalse(is_restricted_go2rtc_source("${NOPE:exec:id}"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -20,7 +20,6 @@ from frigate.api.media_auth import (
|
||||
resolve_media_uri,
|
||||
)
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import EXPORT_DIR
|
||||
from frigate.models import Event, Export, Recordings, ReviewSegment
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
|
||||
|
||||
@@ -246,8 +245,8 @@ class TestExportResolution(unittest.TestCase):
|
||||
camera=camera,
|
||||
name=f"export-{export_id}",
|
||||
date=int(datetime.datetime.now().timestamp()),
|
||||
video_path=os.path.join(EXPORT_DIR, filename),
|
||||
thumb_path=os.path.join(EXPORT_DIR, f"{filename}.jpg"),
|
||||
video_path=f"/media/frigate/exports/{filename}",
|
||||
thumb_path=f"/media/frigate/exports/{filename}.jpg",
|
||||
in_progress=False,
|
||||
).execute()
|
||||
|
||||
|
||||
@@ -46,16 +46,6 @@ class TestNoticeKinds(unittest.TestCase):
|
||||
|
||||
self.assertIsNone(kind.link_for({"version": "0.19.1"}))
|
||||
|
||||
def test_analytics_prompt_links_to_telemetry_settings(self):
|
||||
kind = NOTICE_KINDS["analytics_prompt"]
|
||||
|
||||
self.assertEqual(kind.link_for({}), "/settings?page=systemTelemetry")
|
||||
self.assertFalse(kind.counts_repeats)
|
||||
self.assertFalse(kind.reportable)
|
||||
|
||||
def test_update_available_is_not_reported(self):
|
||||
self.assertFalse(NOTICE_KINDS["update_available"].reportable)
|
||||
|
||||
|
||||
class RegistryTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -357,41 +347,6 @@ class TestNoticeRegistry(RegistryTestCase):
|
||||
self.assertEqual(stats["skipped_detections"]["mutes"], 1)
|
||||
|
||||
|
||||
class TestAnalyticsBaseline(RegistryTestCase):
|
||||
def test_reset_counts_from_now_and_new_kinds_start_at_zero(self):
|
||||
stuck = {"scope": "ov", "params": {"detector": "ov"}}
|
||||
self.registry.raise_notice("detector_stuck", **stuck)
|
||||
self.registry.raise_notice("detector_stuck", **stuck)
|
||||
|
||||
self.registry.reset_analytics_baseline()
|
||||
self.registry.raise_notice("detector_stuck", **stuck)
|
||||
self.registry.raise_notice(
|
||||
"skipped_detections", scope="front_door", params={"pct": 12.5}
|
||||
)
|
||||
|
||||
rows = {row["kind"]: row for row in self.registry.stats()}
|
||||
self.assertEqual(
|
||||
(
|
||||
rows["detector_stuck"]["occurrences"],
|
||||
rows["detector_stuck"]["reported_occurrences"],
|
||||
),
|
||||
(3, 2),
|
||||
)
|
||||
self.assertEqual(rows["skipped_detections"]["reported_occurrences"], 0)
|
||||
|
||||
@patch.dict(NOTICE_KINDS, TEST_KINDS)
|
||||
def test_reset_puts_held_repeats_in_the_baseline(self):
|
||||
for _ in range(3):
|
||||
self.registry.raise_notice("batched")
|
||||
|
||||
self.registry.reset_analytics_baseline()
|
||||
self.registry.flush()
|
||||
|
||||
[row] = self.registry.stats()
|
||||
self.assertEqual((row["occurrences"], row["reported_occurrences"]), (3, 3))
|
||||
self.assertEqual(self.registry.active()[0]["count"], 3)
|
||||
|
||||
|
||||
class TestApply(RegistryTestCase):
|
||||
def test_each_action_reaches_its_method(self):
|
||||
self.registry.apply(
|
||||
|
||||
+16
-38
@@ -46,14 +46,14 @@ DROPPED_DETECTOR_OPTIONS = {
|
||||
}
|
||||
|
||||
|
||||
# Trees the unprivileged runtime user can write. A root service must not
|
||||
# execute a binary from any of them; a compromised uid-1000 process could
|
||||
# Trees the unprivileged runtime user can write. A root frigate service must
|
||||
# not execute a binary from any of them; a compromised uid-1000 process could
|
||||
# plant one and be root after the next restart.
|
||||
RUNTIME_USER_WRITABLE_DIRS = (CONFIG_DIR, BASE_DIR, CACHE_DIR, "/dev/shm", "/tmp")
|
||||
|
||||
|
||||
def service_is_granular_root(service: str) -> bool:
|
||||
"""Report whether FRIGATE_ROOT_SERVICES runs the given service as root.
|
||||
def frigate_service_is_granular_root() -> bool:
|
||||
"""Report whether FRIGATE_ROOT_SERVICES runs frigate as root.
|
||||
|
||||
The escape hatch is excluded: it never sweeps /config and leaves no
|
||||
unprivileged service running, so custom binaries stay as safe as they
|
||||
@@ -66,7 +66,7 @@ def service_is_granular_root(service: str) -> bool:
|
||||
return False
|
||||
|
||||
entries = os.environ.get("FRIGATE_ROOT_SERVICES", "").split(",")
|
||||
return any("".join(entry.split()) == service for entry in entries)
|
||||
return any("".join(entry.split()) == "frigate" for entry in entries)
|
||||
|
||||
|
||||
def is_runtime_user_writable(path: str) -> bool:
|
||||
@@ -79,57 +79,35 @@ def is_runtime_user_writable(path: str) -> bool:
|
||||
|
||||
|
||||
@cache
|
||||
def _warn_ignored_ffmpeg_path(path: str, service: str) -> None:
|
||||
def _warn_ignored_ffmpeg_path(path: str) -> None:
|
||||
"""Warn once per path; resolution runs per camera and per binary."""
|
||||
logger.warning(
|
||||
"Ignoring ffmpeg.path %s because FRIGATE_ROOT_SERVICES runs %s as root and that location is writable by the unprivileged user; using the bundled build",
|
||||
"Ignoring ffmpeg.path %s because FRIGATE_ROOT_SERVICES runs frigate as root and that location is writable by the unprivileged user; using the bundled build",
|
||||
path,
|
||||
service,
|
||||
)
|
||||
|
||||
|
||||
# ffmpeg commands are built as strings and split on whitespace, so a custom path
|
||||
# must stay a single plain token
|
||||
_CUSTOM_FFMPEG_PATH = re.compile(r"/[\w.+@/-]+")
|
||||
|
||||
|
||||
def is_valid_ffmpeg_path(path: str) -> bool:
|
||||
"""Check that a custom ffmpeg path has no whitespace, quotes or shell syntax."""
|
||||
return not path.startswith("/") or _CUSTOM_FFMPEG_PATH.fullmatch(path) is not None
|
||||
|
||||
|
||||
def resolve_ffmpeg_path(
|
||||
path: str, binary: str = "ffmpeg", service: str = "frigate"
|
||||
) -> str:
|
||||
def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str:
|
||||
"""Resolve an ffmpeg version alias or custom path to a binary path.
|
||||
|
||||
A bare version alias that is no longer bundled (for example one that was
|
||||
dropped when the default version changed) falls back to the default
|
||||
bundled version so existing configs keep working across an upgrade or a
|
||||
revert. Custom install paths (anything absolute) are used as-is. While
|
||||
FRIGATE_ROOT_SERVICES makes the service that execs it root, a custom path
|
||||
is resolved to its real location and refused inside a runtime-user-writable
|
||||
tree; see RUNTIME_USER_WRITABLE_DIRS. Paths that fail
|
||||
`is_valid_ffmpeg_path` also fall back to the default.
|
||||
revert. Custom install paths (anything absolute) are used as-is, except
|
||||
one in a runtime-user-writable tree while FRIGATE_ROOT_SERVICES makes
|
||||
frigate root; see RUNTIME_USER_WRITABLE_DIRS.
|
||||
"""
|
||||
if (
|
||||
path == "default"
|
||||
or not is_valid_ffmpeg_path(path)
|
||||
or (not path.startswith("/") and path not in INCLUDED_FFMPEG_VERSIONS)
|
||||
if path == "default" or (
|
||||
not path.startswith("/") and path not in INCLUDED_FFMPEG_VERSIONS
|
||||
):
|
||||
version = DEFAULT_FFMPEG_VERSION
|
||||
elif path in INCLUDED_FFMPEG_VERSIONS:
|
||||
version = path
|
||||
elif not service_is_granular_root(service):
|
||||
return f"{path}/bin/{binary}"
|
||||
else:
|
||||
# return the resolved form so a link in the path cannot be retargeted
|
||||
# after the check
|
||||
real = os.path.realpath(f"{path}/bin/{binary}")
|
||||
if is_valid_ffmpeg_path(real) and not is_runtime_user_writable(real):
|
||||
return real
|
||||
if not (frigate_service_is_granular_root() and is_runtime_user_writable(path)):
|
||||
return f"{path}/bin/{binary}"
|
||||
|
||||
_warn_ignored_ffmpeg_path(path, service)
|
||||
_warn_ignored_ffmpeg_path(path)
|
||||
version = DEFAULT_FFMPEG_VERSION
|
||||
|
||||
return f"/usr/lib/ffmpeg/{version}/bin/{binary}"
|
||||
|
||||
@@ -32,8 +32,8 @@ from pathlib import Path
|
||||
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.util.config import (
|
||||
frigate_service_is_granular_root,
|
||||
is_runtime_user_writable,
|
||||
service_is_granular_root,
|
||||
)
|
||||
from frigate.util.file import FileLock
|
||||
|
||||
@@ -163,7 +163,7 @@ def _usable_reason() -> str | None:
|
||||
|
||||
base = user_base()
|
||||
|
||||
if service_is_granular_root("frigate") and is_runtime_user_writable(str(base)):
|
||||
if frigate_service_is_granular_root() and is_runtime_user_writable(str(base)):
|
||||
return (
|
||||
f"{base} is writable by the unprivileged user while "
|
||||
"FRIGATE_ROOT_SERVICES runs frigate as root"
|
||||
|
||||
@@ -1101,15 +1101,9 @@ def is_go2rtc_arbitrary_exec_allowed() -> bool:
|
||||
|
||||
|
||||
def is_restricted_go2rtc_source(stream_source: str) -> bool:
|
||||
"""Check if a stream source is restricted and the
|
||||
GO2RTC_ALLOW_ARBITRARY_EXEC override is not set.
|
||||
|
||||
Restricted sources are the echo, expr and exec schemes, and any source
|
||||
using go2rtc's `${VAR:default}` expansion, which go2rtc runs over the raw
|
||||
config before parsing and whose default can name an exec source.
|
||||
"""
|
||||
source = stream_source.strip()
|
||||
if not source.startswith(("echo:", "expr:", "exec:")) and "${" not in source:
|
||||
"""Check if a stream source is a restricted type (echo, expr, or exec)
|
||||
and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set."""
|
||||
if not stream_source.strip().startswith(("echo:", "expr:", "exec:")):
|
||||
return False
|
||||
return not is_go2rtc_arbitrary_exec_allowed()
|
||||
|
||||
@@ -1227,74 +1221,6 @@ def find_restricted_go2rtc_stream_changes(
|
||||
return issues
|
||||
|
||||
|
||||
# go2rtc runs `ffmpeg.bin` and `ngrok.command`, and every other `ffmpeg` key is a
|
||||
# template of ffmpeg arguments that `#video=<name>` and friends can reference
|
||||
_GO2RTC_EXEC_SECTIONS = ("ffmpeg", "ngrok")
|
||||
|
||||
|
||||
def find_restricted_go2rtc_exec_changes(
|
||||
old_go2rtc: dict[str, Any] | None, new_go2rtc: dict[str, Any] | None
|
||||
) -> list[str]:
|
||||
"""Return the go2rtc ffmpeg and ngrok keys added or changed through the API.
|
||||
|
||||
These are only accepted with arbitrary exec enabled. Keys already in the
|
||||
saved config are left alone, the same as stream sources.
|
||||
"""
|
||||
if is_go2rtc_arbitrary_exec_allowed():
|
||||
return []
|
||||
|
||||
old_go2rtc = old_go2rtc if isinstance(old_go2rtc, dict) else {}
|
||||
new_go2rtc = new_go2rtc if isinstance(new_go2rtc, dict) else {}
|
||||
changed: list[str] = []
|
||||
|
||||
for section in _GO2RTC_EXEC_SECTIONS:
|
||||
old = old_go2rtc.get(section)
|
||||
new = new_go2rtc.get(section)
|
||||
|
||||
if not isinstance(new, dict):
|
||||
if new is not None and new != old:
|
||||
changed.append(section)
|
||||
continue
|
||||
|
||||
old = old if isinstance(old, dict) else {}
|
||||
changed.extend(
|
||||
f"{section}.{key}" for key, value in new.items() if old.get(key) != value
|
||||
)
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def apply_go2rtc_ffmpeg_config(
|
||||
go2rtc_config: dict[str, Any], ffmpeg_path: str
|
||||
) -> list[str]:
|
||||
"""Set go2rtc's ffmpeg binary for startup.
|
||||
|
||||
Unless arbitrary exec is enabled, `ffmpeg.bin` is forced to Frigate's ffmpeg
|
||||
and `ngrok` is removed. Templates in the saved config are kept.
|
||||
|
||||
Returns:
|
||||
The keys that were overridden or removed
|
||||
"""
|
||||
allowed = is_go2rtc_arbitrary_exec_allowed()
|
||||
restricted: list[str] = []
|
||||
|
||||
ffmpeg = go2rtc_config.get("ffmpeg")
|
||||
if not isinstance(ffmpeg, dict):
|
||||
ffmpeg = go2rtc_config["ffmpeg"] = {}
|
||||
|
||||
if ffmpeg.get("bin") is None:
|
||||
ffmpeg["bin"] = ffmpeg_path
|
||||
elif ffmpeg["bin"] != ffmpeg_path and not allowed:
|
||||
restricted.append("ffmpeg.bin")
|
||||
ffmpeg["bin"] = ffmpeg_path
|
||||
|
||||
if "ngrok" in go2rtc_config and not allowed:
|
||||
restricted.append("ngrok")
|
||||
del go2rtc_config["ngrok"]
|
||||
|
||||
return restricted
|
||||
|
||||
|
||||
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
|
||||
"""Run ffprobe on stream."""
|
||||
clean_path = escape_special_characters(path)
|
||||
|
||||
+1
-13
@@ -690,26 +690,14 @@ class CameraWatchdog(threading.Thread):
|
||||
def stop_all_ffmpeg(self):
|
||||
"""Stop all ffmpeg processes (detection and others)."""
|
||||
logger.debug(f"Stopping all ffmpeg processes for {self.config.name}")
|
||||
capture_reading = False
|
||||
if self.capture_thread is not None and self.capture_thread.is_alive():
|
||||
self.capture_thread.join(timeout=5)
|
||||
if self.capture_thread.is_alive():
|
||||
capture_reading = True
|
||||
self.logger.warning(
|
||||
f"Capture thread for {self.config.name} did not stop gracefully."
|
||||
)
|
||||
if self.ffmpeg_detect_process is not None:
|
||||
# A live capture thread drains stdout, so communicate() would race it
|
||||
# for the pipe. Without a reader, ffmpeg needs communicate() to exit.
|
||||
if capture_reading:
|
||||
self.ffmpeg_detect_process.terminate()
|
||||
try:
|
||||
self.ffmpeg_detect_process.wait(timeout=30)
|
||||
except sp.TimeoutExpired:
|
||||
self.ffmpeg_detect_process.kill()
|
||||
self.ffmpeg_detect_process.wait()
|
||||
else:
|
||||
stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
|
||||
stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
|
||||
self.ffmpeg_detect_process = None
|
||||
for p in self.ffmpeg_other_processes[:]:
|
||||
if p["process"] is not None:
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Generate the analytics report JSON Schema from the Pydantic models.
|
||||
|
||||
The committed docs/static/frigate-analytics-schema.json documents every field
|
||||
for the ingest Worker's aggregation job and the docs page. It is exact for this
|
||||
version only, so the Worker validates just the envelope against it. Never edit
|
||||
it by hand, and never run a formatter over it.
|
||||
|
||||
Usage (from the repository root):
|
||||
|
||||
python3 generate_analytics_schema.py # write the schema
|
||||
python3 generate_analytics_schema.py --check # CI guard: fail if stale
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport
|
||||
|
||||
OUTPUT = Path(__file__).parent / "docs" / "static" / "frigate-analytics-schema.json"
|
||||
|
||||
|
||||
def render() -> str:
|
||||
"""The JSON Schema for the current report models."""
|
||||
schema = AnalyticsReport.model_json_schema()
|
||||
schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
|
||||
schema["$id"] = "https://docs.frigate.video/frigate-analytics-schema.json"
|
||||
schema["x-schema-version"] = SCHEMA_VERSION
|
||||
return json.dumps(schema, indent=2, sort_keys=True) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Write or check the schema file; returns the process exit code."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--check", action="store_true", help="fail if the committed schema is stale"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
rendered = render()
|
||||
|
||||
if args.check:
|
||||
current = OUTPUT.read_text() if OUTPUT.exists() else ""
|
||||
|
||||
if current != rendered:
|
||||
print(
|
||||
f"{OUTPUT} is out of date, run python3 generate_analytics_schema.py",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"{OUTPUT} is up to date")
|
||||
return 0
|
||||
|
||||
OUTPUT.write_text(rendered)
|
||||
print(f"Wrote {OUTPUT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -47,8 +47,8 @@ from fastapi.routing import APIRoute
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
from frigate.api import app as main_app
|
||||
from frigate.api import (
|
||||
analytics,
|
||||
auth,
|
||||
camera,
|
||||
chat,
|
||||
@@ -65,7 +65,6 @@ from frigate.api import (
|
||||
record,
|
||||
review,
|
||||
)
|
||||
from frigate.api import app as main_app
|
||||
from frigate.api.auth import require_admin_by_default
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
@@ -139,7 +138,6 @@ ACCESS_NOTES = {
|
||||
|
||||
# Mirrors the router set wired up in frigate.api.fastapi_app.
|
||||
ROUTERS = [
|
||||
analytics.router,
|
||||
auth.router,
|
||||
camera.router,
|
||||
chat.router,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Section reset button visibility -- MEDIUM tier.
|
||||
*
|
||||
* Reset deletes the section from config.yml, which fails when the key isn't
|
||||
* in the file. The button only shows when the section's values must have
|
||||
* come from the YAML.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect, type FrigateApp } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
configFactory,
|
||||
type DeepPartial,
|
||||
} from "../../fixtures/mock-data/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
async function openSettings(
|
||||
app: FrigateApp,
|
||||
url: string,
|
||||
overrides: DeepPartial<ReturnType<typeof configFactory>> = {},
|
||||
) {
|
||||
const { page } = app;
|
||||
const config = configFactory(overrides);
|
||||
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config", (route) => route.fulfill({ json: config }));
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({ json: { cameras: {}, go2rtc: { streams: {} } } }),
|
||||
);
|
||||
|
||||
await app.goto(url);
|
||||
}
|
||||
|
||||
const resetToDefault = (page: Page) =>
|
||||
page.getByRole("button", { name: "Reset to Default" });
|
||||
const resetToGlobal = (page: Page) =>
|
||||
page.getByRole("button", { name: "Reset to Global" });
|
||||
|
||||
test.describe("section reset visibility @medium", () => {
|
||||
test("global section at its defaults hides reset", async ({ frigateApp }) => {
|
||||
await openSettings(frigateApp, "/settings?page=globalRecording");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText("Retention", { exact: false }).first(),
|
||||
).toBeVisible();
|
||||
await expect(resetToDefault(frigateApp.page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("global section with a changed value shows reset", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await openSettings(frigateApp, "/settings?page=globalRecording", {
|
||||
record: { expire_interval: 30 },
|
||||
});
|
||||
|
||||
await expect(resetToDefault(frigateApp.page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("global ffmpeg with only resolved hwaccel args hides reset", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await openSettings(frigateApp, "/settings?page=globalFfmpeg");
|
||||
|
||||
await expect(
|
||||
frigateApp.page
|
||||
.getByText("Hardware acceleration", { exact: false })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(resetToDefault(frigateApp.page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("camera live with only backend streams hides reset", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await openSettings(
|
||||
frigateApp,
|
||||
"/settings?page=cameraLivePlayback&camera=front_door",
|
||||
);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("switch", {
|
||||
name: "Enable transcoded streams",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(resetToGlobal(frigateApp.page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("camera live with a quality override shows reset", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await openSettings(
|
||||
frigateApp,
|
||||
"/settings?page=cameraLivePlayback&camera=front_door",
|
||||
{ cameras: { front_door: { live: { quality: 4 } } } },
|
||||
);
|
||||
|
||||
await expect(resetToGlobal(frigateApp.page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("camera ffmpeg never shows reset", async ({ frigateApp }) => {
|
||||
await openSettings(
|
||||
frigateApp,
|
||||
"/settings?page=cameraFfmpeg&camera=front_door",
|
||||
{ cameras: { front_door: { ffmpeg: { output_args: { record: "x" } } } } },
|
||||
);
|
||||
|
||||
await expect(
|
||||
frigateApp.page
|
||||
.getByText("Hardware acceleration", { exact: false })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(resetToGlobal(frigateApp.page)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Telemetry settings: the analytics preview shows the report Frigate would
|
||||
* send, fetched only when opened.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
const PREVIEW = {
|
||||
schema_version: 1,
|
||||
install_id: "0".repeat(32),
|
||||
install: { version: "0.19.0-test" },
|
||||
};
|
||||
|
||||
test.describe("telemetry settings: analytics preview @medium", () => {
|
||||
test("shows the report on demand", async ({ frigateApp }) => {
|
||||
let requests = 0;
|
||||
const page = frigateApp.page;
|
||||
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({ json: { telemetry: {} } }),
|
||||
);
|
||||
await page.route("**/api/analytics/preview", (route) => {
|
||||
requests += 1;
|
||||
return route.fulfill({ json: PREVIEW });
|
||||
});
|
||||
|
||||
await frigateApp.goto("/settings?page=systemTelemetry");
|
||||
|
||||
const button = page.getByRole("button", { name: "Preview the report" });
|
||||
await expect(button).toBeVisible();
|
||||
expect(requests).toBe(0);
|
||||
|
||||
await button.click();
|
||||
|
||||
await expect(page.getByTestId("analytics-preview")).toContainText(
|
||||
'"schema_version": 1',
|
||||
);
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user