mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-13 17:13:01 +03:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa8086393b | ||
|
|
4e679ba17a | ||
|
|
06d1365056 | ||
|
|
8a88cefc9a | ||
|
|
fba967c74b | ||
|
|
33c55649a4 | ||
|
|
c88434dc29 | ||
|
|
e7256d58a1 | ||
|
|
b2a7e992c9 | ||
|
|
11f4dd194c | ||
|
|
0e7da1bee4 | ||
|
|
14870f5237 | ||
|
|
ca851a9238 | ||
|
|
81cf7be89a |
@@ -4,6 +4,8 @@ inputs:
|
||||
GITHUB_TOKEN:
|
||||
required: true
|
||||
outputs:
|
||||
image-repo:
|
||||
value: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}
|
||||
image-name:
|
||||
value: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ steps.create-short-sha.outputs.SHORT_SHA }}
|
||||
cache-name:
|
||||
|
||||
@@ -136,9 +136,6 @@ updates:
|
||||
# Later versions need Node 22 and extract false keys
|
||||
- dependency-name: "i18next-cli"
|
||||
versions: [">1.5.11"]
|
||||
# 6.11.0 refills the saved model in the GenAI settings form
|
||||
- dependency-name: "@rjsf/*"
|
||||
versions: ["6.11.0"]
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs"
|
||||
|
||||
@@ -423,6 +423,11 @@ jobs:
|
||||
run: docker rm -f frigate || true
|
||||
arm64_build:
|
||||
runs-on: ubuntu-22.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
name: ARM Build
|
||||
steps:
|
||||
- name: Check out code
|
||||
@@ -435,6 +440,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push arm64 standard build
|
||||
id: standard-arm64
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -445,6 +451,13 @@ jobs:
|
||||
tags: |
|
||||
${{ steps.setup.outputs.image-name }}-standard-arm64
|
||||
cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64
|
||||
- name: Attest arm64 standard build
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-name: ${{ steps.setup.outputs.image-repo }}
|
||||
subject-digest: ${{ steps.standard-arm64.outputs.digest }}
|
||||
push-to-registry: true
|
||||
create-storage-record: false
|
||||
- name: Build and push RPi build
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
@@ -458,6 +471,11 @@ jobs:
|
||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64,mode=max
|
||||
jetson_jp6_build:
|
||||
runs-on: ubuntu-22.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
name: Jetson Jetpack 6
|
||||
steps:
|
||||
- name: Check out code
|
||||
@@ -470,6 +488,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push TensorRT (Jetson, Jetpack 6)
|
||||
id: tensorrt-jp6
|
||||
env:
|
||||
ARCH: arm64
|
||||
BASE_IMAGE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
|
||||
@@ -485,8 +504,20 @@ jobs:
|
||||
tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt-jp6
|
||||
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp6
|
||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp6,mode=max
|
||||
- name: Attest TensorRT (Jetson, Jetpack 6)
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-name: ${{ steps.setup.outputs.image-repo }}
|
||||
subject-digest: ${{ fromJSON(steps.tensorrt-jp6.outputs.metadata)['tensorrt']['containerimage.digest'] }}
|
||||
push-to-registry: true
|
||||
create-storage-record: false
|
||||
amd64_extra_builds:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
name: AMD64 Extra Build
|
||||
needs:
|
||||
- amd64_build
|
||||
@@ -501,6 +532,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push TensorRT (x86 GPU)
|
||||
id: tensorrt
|
||||
env:
|
||||
COMPUTE_LEVEL: "50 60 70 80 90"
|
||||
uses: docker/bake-action@v7
|
||||
@@ -513,7 +545,15 @@ jobs:
|
||||
tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt
|
||||
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-tensorrt
|
||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-tensorrt,mode=max
|
||||
- name: Attest TensorRT (x86 GPU)
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-name: ${{ steps.setup.outputs.image-repo }}
|
||||
subject-digest: ${{ fromJSON(steps.tensorrt.outputs.metadata)['tensorrt']['containerimage.digest'] }}
|
||||
push-to-registry: true
|
||||
create-storage-record: false
|
||||
- name: AMD/ROCm general build
|
||||
id: rocm
|
||||
env:
|
||||
HSA_OVERRIDE: 0
|
||||
uses: docker/bake-action@v7
|
||||
@@ -526,8 +566,20 @@ jobs:
|
||||
rocm.tags=${{ steps.setup.outputs.image-name }}-rocm
|
||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-rocm,mode=max
|
||||
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-rocm
|
||||
- name: Attest AMD/ROCm general build
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-name: ${{ steps.setup.outputs.image-repo }}
|
||||
subject-digest: ${{ fromJSON(steps.rocm.outputs.metadata)['rocm']['containerimage.digest'] }}
|
||||
push-to-registry: true
|
||||
create-storage-record: false
|
||||
arm64_extra_builds:
|
||||
runs-on: ubuntu-22.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
name: ARM Extra Build
|
||||
needs:
|
||||
- arm64_build
|
||||
@@ -542,6 +594,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push Rockchip build
|
||||
id: rk
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
source: .
|
||||
@@ -551,8 +604,20 @@ jobs:
|
||||
set: |
|
||||
rk.tags=${{ steps.setup.outputs.image-name }}-rk
|
||||
*.cache-from=type=gha
|
||||
- name: Attest Rockchip build
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-name: ${{ steps.setup.outputs.image-repo }}
|
||||
subject-digest: ${{ fromJSON(steps.rk.outputs.metadata)['rk']['containerimage.digest'] }}
|
||||
push-to-registry: true
|
||||
create-storage-record: false
|
||||
synaptics_build:
|
||||
runs-on: ubuntu-22.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
name: Synaptics Build
|
||||
needs:
|
||||
- arm64_build
|
||||
@@ -567,6 +632,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push Synaptics build
|
||||
id: synaptics
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
source: .
|
||||
@@ -576,10 +642,22 @@ jobs:
|
||||
set: |
|
||||
synaptics.tags=${{ steps.setup.outputs.image-name }}-synaptics
|
||||
*.cache-from=type=gha
|
||||
- name: Attest Synaptics build
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-name: ${{ steps.setup.outputs.image-repo }}
|
||||
subject-digest: ${{ fromJSON(steps.synaptics.outputs.metadata)['synaptics']['containerimage.digest'] }}
|
||||
push-to-registry: true
|
||||
create-storage-record: false
|
||||
# The majority of users running arm64 are rpi users, so the rpi
|
||||
# build should be the primary arm64 image
|
||||
assemble_default_build:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
name: Assemble and push default build
|
||||
needs:
|
||||
- amd64_build
|
||||
@@ -598,8 +676,16 @@ jobs:
|
||||
- name: Create short sha
|
||||
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||
- uses: int128/docker-manifest-create-action@v2
|
||||
id: manifest
|
||||
with:
|
||||
tags: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}
|
||||
sources: |
|
||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-amd64
|
||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-rpi
|
||||
- name: Attest default build
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-name: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}
|
||||
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||
push-to-registry: true
|
||||
create-storage-record: false
|
||||
|
||||
@@ -46,3 +46,4 @@ make -C /tmp/media-driver/build -j"$(nproc)"
|
||||
|
||||
# Install driver to rootfs for COPY --from
|
||||
make -C /tmp/media-driver/build install DESTDIR=/rootfs
|
||||
strip --strip-unneeded /rootfs/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ruff == 0.17.3
|
||||
ruff == 0.17.4
|
||||
|
||||
# types
|
||||
types-peewee == 4.0.*
|
||||
|
||||
@@ -47,7 +47,7 @@ onnxruntime == 1.31.*
|
||||
# Embeddings
|
||||
transformers == 5.22.*
|
||||
# Generative AI
|
||||
google-genai == 2.34.*
|
||||
google-genai == 3.0.*
|
||||
ollama == 0.6.*
|
||||
openai == 3.46.*
|
||||
# push notifications
|
||||
|
||||
@@ -80,6 +80,11 @@ cp -r /usr/local/nginx/conf/. /tmp/nginx/conf/
|
||||
|
||||
set_worker_processes
|
||||
|
||||
# A non-root master ignores `user` and warns on every start
|
||||
if [[ "$runs_as_root" -eq 0 ]]; then
|
||||
sed -i '/^user root;$/d' /tmp/nginx/conf/nginx.conf
|
||||
fi
|
||||
|
||||
# TLS certs: user-mounted certs at /etc/letsencrypt/live/frigate (documented
|
||||
# contract) always win; otherwise fall back to a self-signed cert persisted in
|
||||
# /config/tls, which stays writable under a read-only root filesystem.
|
||||
|
||||
@@ -105,6 +105,8 @@ if [[ -n "$sentinel" ]]; then
|
||||
mismatch_expr+=(-a ! -path "$sentinel")
|
||||
fi
|
||||
|
||||
exec 3>&1
|
||||
|
||||
for path in "$@"; do
|
||||
# An absent root is an incomplete sweep, not a finished one: /media/frigate
|
||||
# is not in the image, so a boot before the volume is mounted would
|
||||
@@ -121,7 +123,18 @@ for path in "$@"; do
|
||||
# stale mount. Tolerate it rather than aborting under errexit, but never
|
||||
# read a failed scan as "nothing to do": that would record the sweep as
|
||||
# complete without having looked.
|
||||
if ! count=$(find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" -printf '.' 2>/dev/null | wc -c); then
|
||||
# The walk prints nothing until it ends, so report every 30s through fd 3
|
||||
# (stdout) while the count itself comes back through the substitution.
|
||||
if ! count=$(find "$path" -name lost+found -prune -o "(" "${mismatch_expr[@]}" ")" -printf 'm\n' -o -printf '.\n' 2>/dev/null \
|
||||
| awk -v path="$path" '
|
||||
BEGIN { next_report = systime() + 30 }
|
||||
$0 == "m" { mismatched++ }
|
||||
systime() >= next_report {
|
||||
printf "[INFO] fix-ownership: still scanning %s, %d entries checked, %d need changes\n", path, NR, mismatched > "/dev/fd/3"
|
||||
fflush("/dev/fd/3")
|
||||
next_report = systime() + 30
|
||||
}
|
||||
END { print mismatched + 0 }'); then
|
||||
swept_clean=0
|
||||
echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
|
||||
continue
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# file, all other path directives follow --prefix and must stay absolute.
|
||||
|
||||
daemon off;
|
||||
# ignored by a non-root master; keeps workers root under FRIGATE_RUN_AS_ROOT
|
||||
# keeps workers root under FRIGATE_RUN_AS_ROOT; the run script strips it otherwise
|
||||
user root;
|
||||
worker_processes auto;
|
||||
|
||||
|
||||
@@ -71,13 +71,14 @@ Both the script and the boot sweep report progress, so you can tell a slow sweep
|
||||
|
||||
```
|
||||
[INFO] fix-ownership: scanning /media/frigate for ownership mismatches; this may take a while on large filesystems
|
||||
[INFO] fix-ownership: still scanning /media/frigate, 2481000 entries checked, 2390112 need changes
|
||||
[WARN] fix-ownership: adjusting ownership of 4823941 entries under /media/frigate
|
||||
[INFO] fix-ownership: /media/frigate 5% (241197/4823941 entries)
|
||||
[INFO] fix-ownership: /media/frigate 10% (482394/4823941 entries)
|
||||
[INFO] fix-ownership: finished /media/frigate in 12m 4s
|
||||
```
|
||||
|
||||
The scan has no percentage because the total isn't known until it finishes. Watch the boot sweep with `docker logs -f frigate`.
|
||||
The scan has no percentage because the total isn't known until it finishes, so it reports a running count every 30 seconds instead. Watch the boot sweep with `docker logs -f frigate`.
|
||||
|
||||
Once the volumes are aligned, start Frigate normally. A file at `/config/.permissions_version` records what was done, so later boots skip the sweep unless you change `PUID`/`PGID`.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Config, PluginConfig } from "@docusaurus/types";
|
||||
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
||||
|
||||
// Bump when a new stable release ships
|
||||
const STABLE_VERSION = "0.18";
|
||||
const STABLE_VERSION = "0.19";
|
||||
|
||||
const config: Config = {
|
||||
title: "Frigate",
|
||||
|
||||
+23
-1
@@ -683,6 +683,15 @@
|
||||
"title": "FeaturesSection",
|
||||
"type": "object"
|
||||
},
|
||||
"FfmpegSource": {
|
||||
"enum": [
|
||||
"default",
|
||||
"bundled",
|
||||
"custom"
|
||||
],
|
||||
"title": "FfmpegSource",
|
||||
"type": "string"
|
||||
},
|
||||
"FpsBucket": {
|
||||
"enum": [
|
||||
"le_5",
|
||||
@@ -1017,6 +1026,17 @@
|
||||
"description": "CPU architecture",
|
||||
"x-public": true
|
||||
},
|
||||
"custom_go2rtc": {
|
||||
"description": "Whether an executable /config/go2rtc build exists",
|
||||
"title": "Custom Go2Rtc",
|
||||
"type": "boolean",
|
||||
"x-public": true
|
||||
},
|
||||
"ffmpeg": {
|
||||
"$ref": "#/$defs/FfmpegSource",
|
||||
"description": "Configured global ffmpeg.path: the default build, another bundled version, or a custom path",
|
||||
"x-public": true
|
||||
},
|
||||
"image_variant": {
|
||||
"$ref": "#/$defs/ImageVariant",
|
||||
"description": "Published image the install runs",
|
||||
@@ -1054,7 +1074,9 @@
|
||||
"install_type",
|
||||
"arch",
|
||||
"kernel",
|
||||
"run_as_root"
|
||||
"run_as_root",
|
||||
"custom_go2rtc",
|
||||
"ffmpeg"
|
||||
],
|
||||
"title": "InstallSection",
|
||||
"type": "object"
|
||||
|
||||
Vendored
+19
@@ -3148,6 +3148,25 @@ paths:
|
||||
- frigateUserAuth: []
|
||||
x-required-role: any
|
||||
description: '**Access:** Any authenticated user.'
|
||||
/plus/validate:
|
||||
post:
|
||||
tags:
|
||||
- App
|
||||
summary: Validate Plus Key
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Validate the configured Frigate+ API key with the Frigate+ server.
|
||||
operationId: validate_plus_key_plus_validate_post
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/recognized_license_plates:
|
||||
get:
|
||||
tags:
|
||||
|
||||
@@ -6,7 +6,15 @@ 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.analytics.schema import (
|
||||
Arch,
|
||||
FfmpegSource,
|
||||
ImageVariant,
|
||||
InstallSection,
|
||||
InstallType,
|
||||
)
|
||||
from frigate.const import CONFIG_DIR, DEFAULT_FFMPEG_VERSION, INCLUDED_FFMPEG_VERSIONS
|
||||
from frigate.util.config import is_valid_ffmpeg_path
|
||||
from frigate.version import VERSION
|
||||
|
||||
KERNEL_PATTERN = re.compile(r"^(\d{1,3})\.(\d{1,3})")
|
||||
@@ -54,6 +62,17 @@ def kernel(release: str) -> str:
|
||||
return f"{match.group(1)}.{match.group(2)}" if match else "unknown"
|
||||
|
||||
|
||||
def ffmpeg_source(path: str) -> FfmpegSource:
|
||||
"""Classify the configured ffmpeg.path, applying resolve_ffmpeg_path's alias fallbacks."""
|
||||
if path.startswith("/") and is_valid_ffmpeg_path(path):
|
||||
return FfmpegSource.custom
|
||||
|
||||
if path != DEFAULT_FFMPEG_VERSION and path in INCLUDED_FFMPEG_VERSIONS:
|
||||
return FfmpegSource.bundled
|
||||
|
||||
return FfmpegSource.default
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> InstallSection:
|
||||
"""Describe the Frigate version, image, install method, and platform."""
|
||||
return InstallSection(
|
||||
@@ -63,4 +82,6 @@ def collect(ctx: ReportContext) -> InstallSection:
|
||||
arch=arch(platform.machine()),
|
||||
kernel=kernel(platform.release()),
|
||||
run_as_root=os.geteuid() == 0,
|
||||
custom_go2rtc=os.access(os.path.join(CONFIG_DIR, "go2rtc"), os.X_OK),
|
||||
ffmpeg=ffmpeg_source(ctx.config.ffmpeg.path),
|
||||
)
|
||||
|
||||
@@ -47,6 +47,12 @@ class Arch(StrEnum):
|
||||
other = "other"
|
||||
|
||||
|
||||
class FfmpegSource(StrEnum):
|
||||
default = "default"
|
||||
bundled = "bundled"
|
||||
custom = "custom"
|
||||
|
||||
|
||||
class GpuVendor(StrEnum):
|
||||
intel = "intel"
|
||||
amd = "amd"
|
||||
@@ -201,6 +207,10 @@ class InstallSection(AnalyticsModel):
|
||||
"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")
|
||||
custom_go2rtc: bool = metric("Whether an executable /config/go2rtc build exists")
|
||||
ffmpeg: FfmpegSource = metric(
|
||||
"Configured global ffmpeg.path: the default build, another bundled version, or a custom path"
|
||||
)
|
||||
|
||||
|
||||
class GpuInfo(AnalyticsModel):
|
||||
|
||||
+28
-2
@@ -45,7 +45,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL, REPLAY_CAMERA_PREFIX
|
||||
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
||||
from frigate.genai import PROVIDERS, load_providers
|
||||
from frigate.jobs.media_sync import (
|
||||
@@ -54,7 +54,7 @@ from frigate.jobs.media_sync import (
|
||||
start_media_sync_job,
|
||||
)
|
||||
from frigate.models import Event, Timeline
|
||||
from frigate.plus import load_plus_model_info
|
||||
from frigate.plus import PlusKeyStatus, load_plus_model_info
|
||||
from frigate.stats.prometheus import get_metrics, update_metrics
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.builtin import (
|
||||
@@ -979,6 +979,17 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# an unresolvable scene would crash the threads that look up the model
|
||||
detect_update = sections.get("detect")
|
||||
if isinstance(detect_update, dict) and "scene" in detect_update:
|
||||
try:
|
||||
config._resolve_camera_model(cam_name, detect_update["scene"])
|
||||
except (TypeError, ValueError):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Invalid detect scene"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
for section_name, update in sections.items():
|
||||
err = apply_section_update(camera_config, section_name, update)
|
||||
if err is not None:
|
||||
@@ -987,6 +998,11 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# the model is resolved again from the scene when the replay
|
||||
# camera is recycled by the refresh below
|
||||
if "detect" in sections and cam_name.startswith(REPLAY_CAMERA_PREFIX):
|
||||
config.drop_camera_model(cam_name)
|
||||
|
||||
# Publish ZMQ updates so processing threads pick up changes
|
||||
if body.update_topic and body.update_topic.startswith("config/cameras/"):
|
||||
_, _, camera, field = body.update_topic.split("/")
|
||||
@@ -1675,6 +1691,16 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
||||
return JSONResponse(content=validModels)
|
||||
|
||||
|
||||
@router.post("/plus/validate", dependencies=[Depends(require_role(["admin"]))])
|
||||
async def validate_plus_key(request: Request):
|
||||
"""Validate the configured Frigate+ API key with the Frigate+ server."""
|
||||
status = await request.app.frigate_config.plus_api.validate_key()
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": status == PlusKeyStatus.valid, "status": status.value}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
|
||||
)
|
||||
|
||||
+2
-9
@@ -76,7 +76,7 @@ from frigate.models import (
|
||||
from frigate.notices import install_registry
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.object_detection.util import create_detection_input_shm
|
||||
from frigate.output.output import OutputProcess
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
@@ -399,14 +399,7 @@ class FrigateApp:
|
||||
model = self.config.model_for_camera(name)
|
||||
model_cameras[model.scene].append(name)
|
||||
|
||||
try:
|
||||
shm_in = UntrackedSharedMemory(
|
||||
name=name,
|
||||
create=True,
|
||||
size=detection_frame_size(model),
|
||||
)
|
||||
except FileExistsError:
|
||||
shm_in = UntrackedSharedMemory(name=name)
|
||||
shm_in = create_detection_input_shm(name, self.config.models)
|
||||
|
||||
try:
|
||||
shm_out = UntrackedSharedMemory(
|
||||
|
||||
@@ -16,7 +16,7 @@ from frigate.config.camera.updater import (
|
||||
)
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Regions
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.object_detection.util import create_detection_input_shm
|
||||
from frigate.util.builtin import empty_and_close_queue
|
||||
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
|
||||
from frigate.util.object import get_camera_regions_grid
|
||||
@@ -127,13 +127,10 @@ class CameraMaintainer(threading.Thread):
|
||||
max(model.width, model.height),
|
||||
)
|
||||
|
||||
create_detection_input_shm(name, self.config.models)
|
||||
|
||||
try:
|
||||
UntrackedSharedMemory(name=f"out-{name}", create=True, size=20 * 6 * 4)
|
||||
UntrackedSharedMemory(
|
||||
name=name,
|
||||
create=True,
|
||||
size=detection_frame_size(model),
|
||||
)
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -296,7 +296,8 @@ class CameraState:
|
||||
return frame_copy
|
||||
|
||||
def finished(self, obj_id: str) -> None:
|
||||
del self.tracked_objects[obj_id]
|
||||
# state rebuilt after a resolution or scene change no longer has the object
|
||||
self.tracked_objects.pop(obj_id, None)
|
||||
|
||||
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
|
||||
self.callbacks[event_type].append(callback)
|
||||
|
||||
@@ -113,6 +113,23 @@ class AsyncLocalObjectDetector(BaseLocalDetector):
|
||||
return self.detect_api.receive_output()
|
||||
|
||||
|
||||
def map_input_frame(
|
||||
frame_manager: SharedMemoryFrameManager, camera: str, model: ModelConfig
|
||||
) -> np.ndarray | None:
|
||||
"""Map a camera's detection input segment as this model's input tensor.
|
||||
|
||||
The segment is sized for the largest configured model, so it can be larger
|
||||
than this model's input.
|
||||
"""
|
||||
buf = frame_manager.write(camera)
|
||||
shape = (1, model.height, model.width, 3)
|
||||
|
||||
if buf is None or len(buf) < model.height * model.width * 3:
|
||||
return None
|
||||
|
||||
return np.ndarray(shape, dtype=np.uint8, buffer=buf)
|
||||
|
||||
|
||||
class DetectorRunner(FrigateProcess):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -154,14 +171,10 @@ class DetectorRunner(FrigateProcess):
|
||||
connection_id = self.detection_queue.get(timeout=1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
input_frame = frame_manager.get(
|
||||
input_frame = map_input_frame(
|
||||
frame_manager,
|
||||
connection_id,
|
||||
(
|
||||
1,
|
||||
self.detector_config.model.height, # type: ignore[union-attr]
|
||||
self.detector_config.model.width, # type: ignore[union-attr]
|
||||
3,
|
||||
),
|
||||
self.detector_config.model, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
if input_frame is None:
|
||||
@@ -227,14 +240,10 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
continue
|
||||
|
||||
assert self._frame_manager is not None
|
||||
input_frame = self._frame_manager.get(
|
||||
input_frame = map_input_frame(
|
||||
self._frame_manager,
|
||||
connection_id,
|
||||
(
|
||||
1,
|
||||
self.detector_config.model.height, # type: ignore[union-attr]
|
||||
self.detector_config.model.width, # type: ignore[union-attr]
|
||||
3,
|
||||
),
|
||||
self.detector_config.model, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
if input_frame is None:
|
||||
|
||||
@@ -6,18 +6,39 @@ import threading
|
||||
from numpy import ndarray
|
||||
|
||||
from frigate.detectors.detector_config import InputTensorEnum, ModelConfig
|
||||
from frigate.util.image import UntrackedSharedMemory
|
||||
|
||||
|
||||
def detection_frame_size(model: ModelConfig) -> int:
|
||||
"""Get the shared memory size a camera needs to hand frames to a model.
|
||||
def create_detection_input_shm(
|
||||
camera: str, models: list[ModelConfig]
|
||||
) -> UntrackedSharedMemory:
|
||||
"""Create the shared memory a camera hands detection frames through.
|
||||
|
||||
It is sized for the largest model so a camera re-added under the same name
|
||||
with another scene can reuse it. Detectors keep mappings of it, so it is
|
||||
never unlinked while they run.
|
||||
|
||||
Args:
|
||||
model: The model the camera runs on
|
||||
camera: Name of the camera
|
||||
models: Every configured model
|
||||
|
||||
Returns:
|
||||
Size in bytes of one model input frame
|
||||
The camera's detection input segment
|
||||
"""
|
||||
return model.height * model.width * 3
|
||||
size = max(model.height * model.width * 3 for model in models)
|
||||
|
||||
try:
|
||||
return UntrackedSharedMemory(name=camera, create=True, size=size)
|
||||
except FileExistsError:
|
||||
shm = UntrackedSharedMemory(name=camera)
|
||||
|
||||
if shm.size >= size:
|
||||
return shm
|
||||
|
||||
# left by an earlier run with smaller models, so no detector maps it yet
|
||||
shm.close()
|
||||
shm.unlink()
|
||||
return UntrackedSharedMemory(name=camera, create=True, size=size)
|
||||
|
||||
|
||||
class RequestStore:
|
||||
|
||||
+81
-1
@@ -3,9 +3,11 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, TypeGuard
|
||||
|
||||
import aiohttp
|
||||
import cv2
|
||||
import requests
|
||||
from numpy import ndarray
|
||||
@@ -17,6 +19,17 @@ from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLUS_VALIDATE_TIMEOUT = 10
|
||||
|
||||
|
||||
class PlusKeyStatus(str, Enum):
|
||||
"""Result of validating a Frigate+ API key with the server."""
|
||||
|
||||
valid = "valid"
|
||||
invalid = "invalid"
|
||||
unreachable = "unreachable"
|
||||
not_set = "not_set"
|
||||
|
||||
|
||||
def add_hailo_alias(model_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Name the hailo detector by its current key as well as its old one.
|
||||
@@ -72,6 +85,19 @@ def get_jpg_bytes(image: ndarray, max_dim: int, quality: int) -> bytes:
|
||||
return jpg_bytes if isinstance(jpg_bytes, bytes) else b""
|
||||
|
||||
|
||||
def _is_usable_token(token_data: Any) -> TypeGuard[dict[str, Any]]:
|
||||
"""Whether a Frigate+ token response has a usable token and expiry."""
|
||||
if not isinstance(token_data, dict):
|
||||
return False
|
||||
|
||||
expires = token_data.get("expires")
|
||||
return (
|
||||
isinstance(token_data.get("accessToken"), str)
|
||||
and isinstance(expires, (int, float))
|
||||
and not isinstance(expires, bool)
|
||||
)
|
||||
|
||||
|
||||
class PlusApi:
|
||||
def __init__(self) -> None:
|
||||
self.host = PLUS_API_HOST
|
||||
@@ -153,6 +179,60 @@ class PlusApi:
|
||||
def is_active(self) -> bool:
|
||||
return self._is_active
|
||||
|
||||
async def validate_key(self) -> PlusKeyStatus:
|
||||
"""Check the configured API key against the Frigate+ server.
|
||||
|
||||
The key format is checked at startup, but only the server can tell
|
||||
whether the key is actually valid. This requests a fresh token so the
|
||||
result reflects the key's current state rather than a cached token.
|
||||
|
||||
Returns:
|
||||
The key's status as reported by the Frigate+ server
|
||||
"""
|
||||
if self.key is None:
|
||||
return PlusKeyStatus.not_set
|
||||
|
||||
key_id, _, secret = self.key.partition(":")
|
||||
|
||||
# Use a single request with a timeout instead of the retrying session,
|
||||
# since this is user initiated and should fail fast when unreachable.
|
||||
# trust_env honors proxy settings, matching the other Frigate+ requests.
|
||||
try:
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=PLUS_VALIDATE_TIMEOUT),
|
||||
trust_env=True,
|
||||
) as session:
|
||||
async with session.get(
|
||||
f"{self.host}/v1/auth/token",
|
||||
auth=aiohttp.BasicAuth(key_id, secret),
|
||||
) as r:
|
||||
status = r.status
|
||||
token_data = await r.json(content_type=None) if r.ok else None
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
logger.warning("Unable to reach Frigate+ to validate API key: %s", err)
|
||||
return PlusKeyStatus.unreachable
|
||||
except ValueError:
|
||||
logger.warning("Invalid response validating Frigate+ API key")
|
||||
return PlusKeyStatus.unreachable
|
||||
|
||||
if status in (401, 403):
|
||||
return PlusKeyStatus.invalid
|
||||
|
||||
if status >= 400:
|
||||
logger.warning(
|
||||
"Unexpected response validating Frigate+ API key: %s", status
|
||||
)
|
||||
return PlusKeyStatus.unreachable
|
||||
|
||||
# Keep the cached token unless the reply is a usable one, so an
|
||||
# unexpected response can't break later Frigate+ requests
|
||||
if not _is_usable_token(token_data):
|
||||
logger.warning("Invalid response validating Frigate+ API key")
|
||||
return PlusKeyStatus.unreachable
|
||||
|
||||
self._token_data = token_data
|
||||
return PlusKeyStatus.valid
|
||||
|
||||
def upload_image(self, image: ndarray, camera: str) -> str:
|
||||
r = self._get("image/signed_urls")
|
||||
if not r.ok:
|
||||
|
||||
+40
-51
@@ -585,26 +585,36 @@ class PtzAutoTracker(threading.Thread):
|
||||
|
||||
def _predict_movement_time(self, camera, pan, tilt):
|
||||
combined_movement = abs(pan) + abs(tilt)
|
||||
input_data = np.array([self.intercept[camera], combined_movement])
|
||||
input_data = np.array([1, combined_movement])
|
||||
|
||||
return np.dot(self.move_coefficients[camera], input_data)
|
||||
|
||||
def _predict_area_after_time(self, camera, time):
|
||||
return np.dot(
|
||||
self.tracked_object_metrics[camera]["area_coefficients"],
|
||||
[self.tracked_object_history[camera][-1]["frame_time"] + time],
|
||||
)
|
||||
|
||||
def _predict_target_box(self, camera, predicted_time):
|
||||
target_box = self.tracked_object_metrics[camera]["target_box"]
|
||||
tom = self.tracked_object_metrics[camera]
|
||||
target_box = tom["target_box"]
|
||||
|
||||
if not predicted_time:
|
||||
return target_box
|
||||
|
||||
frame_shape = self.config.cameras[camera].frame_shape
|
||||
camera_config = self.config.cameras[camera]
|
||||
left, top, right, bottom = self.tracked_object_history[camera][-1]["box"]
|
||||
size = max(right - left, bottom - top)
|
||||
|
||||
return target_box + self._predict_area_after_time(camera, predicted_time) / (
|
||||
frame_shape[0] * frame_shape[1]
|
||||
if size <= 0:
|
||||
return target_box
|
||||
|
||||
# the corner velocities diverge as the object approaches the camera
|
||||
frames = camera_config.detect.fps * predicted_time
|
||||
velocity = tom["velocity"]
|
||||
predicted_size = max(
|
||||
right - left + frames * (velocity[2] - velocity[0]),
|
||||
bottom - top + frames * (velocity[3] - velocity[1]),
|
||||
1,
|
||||
)
|
||||
|
||||
# target_box is the squared size raised to the zoom factor
|
||||
return target_box * (predicted_size / size) ** (
|
||||
2 * camera_config.onvif.autotracking.zoom_factor
|
||||
)
|
||||
|
||||
def _calculate_tracked_object_metrics(self, camera, obj):
|
||||
@@ -660,26 +670,6 @@ class PtzAutoTracker(threading.Thread):
|
||||
|
||||
filtered_areas = remove_outliers(areas) if len(areas) > 3 else areas
|
||||
|
||||
# Filter entries that are not touching the frame edge
|
||||
filtered_areas_not_touching_edge = [
|
||||
entry
|
||||
for entry in filtered_areas
|
||||
if self._touching_frame_edges(camera, entry["box"]) == 0
|
||||
]
|
||||
|
||||
# Calculate regression for area change predictions
|
||||
if len(filtered_areas_not_touching_edge):
|
||||
X = np.array(
|
||||
[item["frame_time"] for item in filtered_areas_not_touching_edge]
|
||||
)
|
||||
y = np.array([item["area"] for item in filtered_areas_not_touching_edge])
|
||||
|
||||
tom["area_coefficients"] = np.linalg.lstsq(X.reshape(-1, 1), y, rcond=None)[
|
||||
0
|
||||
]
|
||||
else:
|
||||
tom["area_coefficients"] = np.array([0])
|
||||
|
||||
weights = np.arange(1, len(filtered_areas) + 1)
|
||||
weighted_area = np.average(
|
||||
[item["area"] for item in filtered_areas], weights=weights
|
||||
@@ -744,6 +734,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
continue
|
||||
|
||||
else:
|
||||
pan_tilt_times = None
|
||||
|
||||
if (
|
||||
camera_config.onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
@@ -755,17 +747,28 @@ class PtzAutoTracker(threading.Thread):
|
||||
|
||||
await self._wait_until_stopped(camera, metrics)
|
||||
|
||||
# _zoom_absolute resets the metrics timestamps
|
||||
pan_tilt_times = (
|
||||
metrics.start_time.value,
|
||||
metrics.stop_time.value,
|
||||
)
|
||||
|
||||
if zoom > 0 and metrics.zoom_level.value != zoom:
|
||||
await self.onvif._zoom_absolute(camera, zoom, 1)
|
||||
|
||||
await self._wait_until_stopped(camera, metrics)
|
||||
|
||||
start_time, stop_time = pan_tilt_times or (
|
||||
metrics.start_time.value,
|
||||
metrics.stop_time.value,
|
||||
)
|
||||
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
logger.debug(
|
||||
f"{camera}: Predicted movement time: {self._predict_movement_time(camera, pan, tilt)}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Actual movement time: {metrics.stop_time.value - metrics.start_time.value}"
|
||||
f"{camera}: Actual movement time: {stop_time - start_time}"
|
||||
)
|
||||
|
||||
# save metrics for better estimate calculations
|
||||
@@ -781,8 +784,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
{
|
||||
"pan": pan,
|
||||
"tilt": tilt,
|
||||
"start_timestamp": metrics.start_time.value,
|
||||
"end_timestamp": metrics.stop_time.value,
|
||||
"start_timestamp": start_time,
|
||||
"end_timestamp": stop_time,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -845,7 +848,6 @@ class PtzAutoTracker(threading.Thread):
|
||||
# Thresholds
|
||||
x_mags_thresh = camera_width / camera_fps / 2
|
||||
y_mags_thresh = camera_height / camera_fps / 2
|
||||
dir_thresh = 0.93
|
||||
delta_thresh = 20
|
||||
var_thresh = 10
|
||||
|
||||
@@ -863,24 +865,12 @@ class PtzAutoTracker(threading.Thread):
|
||||
stdev_list = np.std(velocities, axis=0)
|
||||
high_variances = np.any(stdev_list > var_thresh)
|
||||
|
||||
# Check direction difference
|
||||
# the corners of an object moving toward or away from the camera move
|
||||
# in different directions, so the delta bounds how far they may differ
|
||||
velocities = np.round(velocities)
|
||||
invalid_dirs = False
|
||||
if np.all(np.linalg.norm(velocities, axis=1)):
|
||||
cosine_sim = np.dot(velocities[0], velocities[1]) / (
|
||||
np.linalg.norm(velocities[0]) * np.linalg.norm(velocities[1])
|
||||
)
|
||||
dir_thresh = 0.6 if np.all(delta < delta_thresh / 2) else dir_thresh
|
||||
invalid_dirs = cosine_sim < dir_thresh
|
||||
|
||||
# Combine
|
||||
invalid = (
|
||||
invalid_x_mags
|
||||
or invalid_y_mags
|
||||
or invalid_dirs
|
||||
or invalid_delta
|
||||
or high_variances
|
||||
)
|
||||
invalid = invalid_x_mags or invalid_y_mags or invalid_delta or high_variances
|
||||
|
||||
if invalid:
|
||||
logger.debug(
|
||||
@@ -891,7 +881,6 @@ class PtzAutoTracker(threading.Thread):
|
||||
for var_name, is_invalid in [
|
||||
("invalid_x_mags", invalid_x_mags),
|
||||
("invalid_y_mags", invalid_y_mags),
|
||||
("invalid_dirs", invalid_dirs),
|
||||
("invalid_delta", invalid_delta),
|
||||
("high_variances", high_variances),
|
||||
]
|
||||
|
||||
@@ -98,6 +98,46 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert response.status_code == 200
|
||||
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_config_set_in_memory_rebinds_replay_model_on_scene_change(
|
||||
self, mock_labels
|
||||
):
|
||||
mock_labels.return_value = {}
|
||||
replay = "_replay_front_door"
|
||||
self.minimal_config["models"] = [
|
||||
{"devices": ["cpu"]},
|
||||
{"scene": "thermal", "devices": ["openvino:CPU"], "width": 300},
|
||||
]
|
||||
self.minimal_config["cameras"][replay] = self.minimal_config["cameras"].pop(
|
||||
"front_door"
|
||||
)
|
||||
app = super().create_app()
|
||||
app.config_publisher = Mock()
|
||||
assert app.frigate_config.model_for_camera(replay).scene == "default"
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"requires_restart": 0,
|
||||
"skip_save": True,
|
||||
"update_topic": f"config/cameras/{replay}/detect",
|
||||
"config_data": {
|
||||
"cameras": {replay: {"detect": {"scene": "thermal"}}}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert app.frigate_config.cameras[replay].detect.scene == "thermal"
|
||||
assert app.frigate_config.model_for_camera(replay).scene == "thermal"
|
||||
topics = [
|
||||
call.args[0].update_type.name
|
||||
for call in app.config_publisher.publish_update.call_args_list
|
||||
]
|
||||
assert topics[0] == "detect"
|
||||
assert topics[-1] == "refresh"
|
||||
|
||||
####################################################################################################################
|
||||
################################### Credential redaction sentinel ################################################
|
||||
####################################################################################################################
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for the Frigate+ API endpoints."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.models import Event
|
||||
from frigate.plus import PlusKeyStatus
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpPlusValidate(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Event])
|
||||
|
||||
def _validate(self, status: PlusKeyStatus) -> dict:
|
||||
app = self.create_app()
|
||||
|
||||
with patch.object(
|
||||
app.frigate_config.plus_api, "validate_key", return_value=status
|
||||
):
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.post("/plus/validate")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response.json()
|
||||
|
||||
def test_a_valid_key(self):
|
||||
self.assertEqual(
|
||||
self._validate(PlusKeyStatus.valid), {"success": True, "status": "valid"}
|
||||
)
|
||||
|
||||
def test_an_invalid_key(self):
|
||||
self.assertEqual(
|
||||
self._validate(PlusKeyStatus.invalid),
|
||||
{"success": False, "status": "invalid"},
|
||||
)
|
||||
|
||||
def test_viewers_cannot_validate(self):
|
||||
app = self.create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.post(
|
||||
"/plus/validate",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -12,6 +12,7 @@ from frigate.analytics.collectors.common import closed, histogram, rate
|
||||
from frigate.analytics.schema import (
|
||||
Arch,
|
||||
DecodeFamily,
|
||||
FfmpegSource,
|
||||
GpuVendor,
|
||||
HardwareKey,
|
||||
ImageVariant,
|
||||
@@ -56,6 +57,36 @@ class TestInstallCollector(unittest.TestCase):
|
||||
self.assertEqual(section.arch, Arch.aarch64)
|
||||
self.assertEqual(section.kernel, "6.8")
|
||||
self.assertFalse(section.run_as_root)
|
||||
self.assertEqual(section.ffmpeg, FfmpegSource.default)
|
||||
|
||||
def test_ffmpeg_source_follows_path_resolution(self):
|
||||
with (
|
||||
patch.object(install, "DEFAULT_FFMPEG_VERSION", "8.0"),
|
||||
patch.object(install, "INCLUDED_FFMPEG_VERSIONS", ["8.0", "7.0"]),
|
||||
):
|
||||
self.assertEqual(install.ffmpeg_source("default"), FfmpegSource.default)
|
||||
self.assertEqual(install.ffmpeg_source("8.0"), FfmpegSource.default)
|
||||
self.assertEqual(install.ffmpeg_source("4.4"), FfmpegSource.default)
|
||||
self.assertEqual(install.ffmpeg_source("7.0"), FfmpegSource.bundled)
|
||||
self.assertEqual(
|
||||
install.ffmpeg_source("/config/custom-ffmpeg"), FfmpegSource.custom
|
||||
)
|
||||
self.assertEqual(
|
||||
install.ffmpeg_source("/config/a b; rm"), FfmpegSource.default
|
||||
)
|
||||
|
||||
def test_custom_go2rtc_needs_an_executable_binary(self):
|
||||
with tempfile.TemporaryDirectory() as config_dir:
|
||||
binary = os.path.join(config_dir, "go2rtc")
|
||||
|
||||
with patch.object(install, "CONFIG_DIR", config_dir):
|
||||
self.assertFalse(install.collect(make_context()).custom_go2rtc)
|
||||
|
||||
open(binary, "w").close()
|
||||
self.assertFalse(install.collect(make_context()).custom_go2rtc)
|
||||
|
||||
os.chmod(binary, 0o755)
|
||||
self.assertTrue(install.collect(make_context()).custom_go2rtc)
|
||||
|
||||
def test_image_variant_is_dev_when_unset_and_other_when_unknown(self):
|
||||
self.assertEqual(install.image_variant(None), ImageVariant.dev)
|
||||
|
||||
@@ -25,6 +25,8 @@ INSTALL = InstallSection(
|
||||
arch="x86_64",
|
||||
kernel="6.8",
|
||||
run_as_root=False,
|
||||
custom_go2rtc=False,
|
||||
ffmpeg="default",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for migrating detectors and model into the models list."""
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
@@ -240,6 +241,42 @@ class TestMigrateConfigFile(unittest.TestCase):
|
||||
with open(self.config_file) as f:
|
||||
return YAML().load(f)
|
||||
|
||||
def test_a_failed_write_leaves_the_original_config(self):
|
||||
original = "mqtt:\n enabled: false\ncameras: {}\n"
|
||||
with open(self.config_file, "w") as f:
|
||||
f.write(original)
|
||||
|
||||
with (
|
||||
patch.object(YAML, "dump", side_effect=RuntimeError),
|
||||
self.assertRaises(RuntimeError),
|
||||
):
|
||||
migrate_frigate_config(self.config_file)
|
||||
|
||||
with open(self.config_file) as f:
|
||||
self.assertEqual(f.read(), original)
|
||||
|
||||
def test_a_file_bind_mount_is_written_in_place(self):
|
||||
with patch(
|
||||
"frigate.util.config.os.replace",
|
||||
side_effect=OSError(errno.EBUSY, "Device or resource busy"),
|
||||
):
|
||||
migrated = self._migrate("mqtt:\n enabled: false\ncameras: {}\n")
|
||||
|
||||
self.assertEqual(migrated["version"], CURRENT_CONFIG_VERSION)
|
||||
self.assertFalse(os.path.exists(f"{self.config_file}.tmp"))
|
||||
|
||||
def test_a_symlinked_config_migrates_its_target(self):
|
||||
target = os.path.join(self.temp_dir.name, "frigate.yml")
|
||||
with open(target, "w") as f:
|
||||
f.write("mqtt:\n enabled: false\ncameras: {}\n")
|
||||
os.symlink(target, self.config_file)
|
||||
|
||||
migrate_frigate_config(self.config_file)
|
||||
|
||||
self.assertTrue(os.path.islink(self.config_file))
|
||||
with open(target) as f:
|
||||
self.assertEqual(YAML().load(f)["version"], CURRENT_CONFIG_VERSION)
|
||||
|
||||
def test_migrates_a_config_already_stamped_with_the_current_version(self):
|
||||
# 0.19 is unreleased, so a dev config can be current and still use
|
||||
# the pre-models keys
|
||||
|
||||
+138
-1
@@ -1,12 +1,17 @@
|
||||
"""Tests for Frigate+ model metadata."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.plus import PlusApi, load_plus_model_info
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestServer
|
||||
|
||||
from frigate.plus import PlusApi, PlusKeyStatus, load_plus_model_info
|
||||
|
||||
|
||||
class TestHailoAlias(unittest.TestCase):
|
||||
@@ -75,5 +80,137 @@ class TestLoadPlusModelInfo(unittest.TestCase):
|
||||
self.assertIsNone(load_plus_model_info("bad"))
|
||||
|
||||
|
||||
class TestValidateKey(unittest.IsolatedAsyncioTestCase):
|
||||
"""The key format is checked locally, but only the server knows if it is valid."""
|
||||
|
||||
CACHED_TOKEN = {"accessToken": "cached", "expires": 2000000000}
|
||||
|
||||
async def _serve(self, handler) -> str:
|
||||
"""Serve the token endpoint locally and return its base url."""
|
||||
app = web.Application()
|
||||
app.router.add_get("/v1/auth/token", handler)
|
||||
server = TestServer(app)
|
||||
await server.start_server()
|
||||
self.addAsyncCleanup(server.close)
|
||||
return str(server.make_url("")).rstrip("/")
|
||||
|
||||
def _api(self, host: str, key: str | None = "id:secret") -> PlusApi:
|
||||
api = PlusApi.__new__(PlusApi)
|
||||
api.host = host
|
||||
api.key = key
|
||||
api._token_data = dict(self.CACHED_TOKEN)
|
||||
return api
|
||||
|
||||
async def _validate(self, handler) -> tuple[PlusKeyStatus, PlusApi]:
|
||||
api = self._api(await self._serve(handler))
|
||||
return await api.validate_key(), api
|
||||
|
||||
def _replies(self, status: int = 200, body: object = None, text: str | None = None):
|
||||
async def handler(request: web.Request) -> web.Response:
|
||||
self.auth = request.headers.get("Authorization")
|
||||
if text is not None:
|
||||
return web.Response(status=status, text=text)
|
||||
return web.json_response(body, status=status)
|
||||
|
||||
return handler
|
||||
|
||||
async def test_an_accepted_key_is_valid(self):
|
||||
token = {"accessToken": "fresh", "expires": 2000000500.5}
|
||||
status, api = await self._validate(self._replies(body=token))
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.valid)
|
||||
self.assertEqual(api._token_data, token)
|
||||
self.assertEqual(self.auth, aiohttp.BasicAuth("id", "secret").encode())
|
||||
|
||||
async def test_a_rejected_key_is_invalid(self):
|
||||
for code in (401, 403):
|
||||
status, api = await self._validate(self._replies(code, {"error": "no"}))
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.invalid)
|
||||
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
||||
|
||||
async def test_a_server_error_is_not_reported_as_invalid(self):
|
||||
status, _ = await self._validate(self._replies(500, {"error": "down"}))
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||
|
||||
async def test_unusable_replies_keep_the_cached_token(self):
|
||||
# a successful reply without a usable token must not replace one that
|
||||
# later Frigate+ requests depend on
|
||||
for body in (
|
||||
None,
|
||||
[],
|
||||
{},
|
||||
{"accessToken": "t"},
|
||||
{"expires": 2000000000},
|
||||
{"accessToken": 1, "expires": 2000000000},
|
||||
{"accessToken": "t", "expires": "soon"},
|
||||
{"accessToken": "t", "expires": True},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
status, api = await self._validate(self._replies(body=body))
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
||||
|
||||
async def test_a_non_json_reply_keeps_the_cached_token(self):
|
||||
status, api = await self._validate(self._replies(text="<html>"))
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
||||
|
||||
async def test_a_connection_failure_is_unreachable(self):
|
||||
# serve and stop, so the port refuses connections
|
||||
server = TestServer(web.Application())
|
||||
await server.start_server()
|
||||
host = str(server.make_url("")).rstrip("/")
|
||||
await server.close()
|
||||
api = self._api(host)
|
||||
|
||||
self.assertEqual(await api.validate_key(), PlusKeyStatus.unreachable)
|
||||
|
||||
async def test_a_slow_server_times_out_as_unreachable(self):
|
||||
async def slow(request: web.Request) -> web.Response:
|
||||
await asyncio.sleep(5)
|
||||
return web.json_response(self.CACHED_TOKEN)
|
||||
|
||||
with patch("frigate.plus.PLUS_VALIDATE_TIMEOUT", 0.1):
|
||||
status, _ = await self._validate(slow)
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||
|
||||
async def test_the_request_goes_through_a_configured_proxy(self):
|
||||
# installs that reach Frigate+ through a proxy must validate the same way
|
||||
token = {"accessToken": "proxied", "expires": 2000000000}
|
||||
proxied = []
|
||||
|
||||
async def proxy(request: web.Request) -> web.Response:
|
||||
proxied.append(str(request.url))
|
||||
return web.json_response(token)
|
||||
|
||||
proxy_url = await self._serve(proxy)
|
||||
api = self._api("http://plus.invalid")
|
||||
|
||||
with patch.dict(
|
||||
os.environ, {"HTTP_PROXY": proxy_url, "NO_PROXY": ""}, clear=False
|
||||
):
|
||||
status = await api.validate_key()
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.valid)
|
||||
self.assertEqual(proxied, ["http://plus.invalid/v1/auth/token"])
|
||||
|
||||
async def test_no_key_is_not_sent_to_the_server(self):
|
||||
requests_seen = []
|
||||
|
||||
async def handler(request: web.Request) -> web.Response:
|
||||
requests_seen.append(request)
|
||||
return web.json_response(self.CACHED_TOKEN)
|
||||
|
||||
api = self._api(await self._serve(handler), key=None)
|
||||
|
||||
self.assertEqual(await api.validate_key(), PlusKeyStatus.not_set)
|
||||
self.assertEqual(requests_seen, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -11,11 +11,14 @@ KeyError on the autotracker thread or silently keep the wrong state:
|
||||
running its motion estimator for a camera that could not autotrack.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config import FrigateConfig, ZoomingModeEnum
|
||||
from frigate.config.camera.updater import CameraConfigUpdateEnum
|
||||
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
||||
|
||||
@@ -123,6 +126,118 @@ class TestAutotrackerDisable(unittest.TestCase):
|
||||
self.assertIs(payload, autotracking)
|
||||
|
||||
|
||||
class TestAutotrackerPrediction(unittest.TestCase):
|
||||
def test_movement_time_uses_the_fitted_intercept_unchanged(self) -> None:
|
||||
tracker = _make_tracker()
|
||||
tracker.intercept = {CAMERA: 0.5}
|
||||
tracker.move_coefficients = {CAMERA: [0.3, 0.8]}
|
||||
|
||||
self.assertAlmostEqual(tracker._predict_movement_time(CAMERA, 0.25, 0.25), 0.7)
|
||||
|
||||
def test_shrinking_box_keeps_a_positive_target_box(self) -> None:
|
||||
# 60 px shrinking 6 px/frame reaches zero after 2 s at 5 fps
|
||||
target_box = self._target_box_for((0, 0, 60, 60), [3, 3, -3, -3], 2.0)
|
||||
|
||||
self.assertGreater(target_box, 0)
|
||||
self.assertTrue(np.isfinite(target_box))
|
||||
|
||||
def test_approaching_object_keeps_its_velocity(self) -> None:
|
||||
# the box grows, so the corners move in opposite directions
|
||||
tracker = _make_tracker()
|
||||
obj = MagicMock()
|
||||
obj.obj_data = {"estimate_velocity": np.array([[-3.0, -4.0], [3.0, 1.0]])}
|
||||
|
||||
valid, velocity = tracker._get_valid_velocity(CAMERA, obj)
|
||||
|
||||
self.assertTrue(valid)
|
||||
self.assertEqual(list(velocity), [-3, -4, 3, 1])
|
||||
|
||||
def test_corners_that_disagree_too_much_are_rejected(self) -> None:
|
||||
tracker = _make_tracker()
|
||||
obj = MagicMock()
|
||||
obj.obj_data = {"estimate_velocity": np.array([[-15.0, 0.0], [15.0, 0.0]])}
|
||||
|
||||
valid, velocity = tracker._get_valid_velocity(CAMERA, obj)
|
||||
|
||||
self.assertFalse(valid)
|
||||
self.assertFalse(np.any(velocity))
|
||||
|
||||
def _target_box(self, velocity: list[float], predicted_time: float) -> float:
|
||||
return self._target_box_for((900, 400, 1000, 600), velocity, predicted_time)
|
||||
|
||||
def _target_box_for(
|
||||
self,
|
||||
box: tuple[int, int, int, int],
|
||||
velocity: list[float],
|
||||
predicted_time: float,
|
||||
) -> float:
|
||||
tracker = _make_tracker()
|
||||
tracker.tracked_object_history = {CAMERA: [{"box": box}]}
|
||||
tracker.tracked_object_metrics = {
|
||||
CAMERA: {"target_box": 0.2, "velocity": np.array(velocity)}
|
||||
}
|
||||
|
||||
return tracker._predict_target_box(CAMERA, predicted_time)
|
||||
|
||||
def test_target_box_grows_with_an_approaching_object(self) -> None:
|
||||
# the 200px height gains 4px per frame at 5 fps for 1 second
|
||||
zoom_factor = 0.3
|
||||
|
||||
self.assertAlmostEqual(
|
||||
self._target_box([-1, -2, 1, 2], 1.0),
|
||||
0.2 * (220 / 200) ** (2 * zoom_factor),
|
||||
)
|
||||
|
||||
def test_target_box_is_unchanged_without_growth_or_time(self) -> None:
|
||||
self.assertAlmostEqual(self._target_box([5, 0, 5, 0], 1.0), 0.2)
|
||||
self.assertEqual(self._target_box([-1, -2, 1, 2], 0), 0.2)
|
||||
|
||||
|
||||
class TestAutotrackerMoveQueue(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_absolute_zoom_does_not_overwrite_the_pan_tilt_sample(self) -> None:
|
||||
tracker = _make_tracker()
|
||||
autotracking = tracker.config.cameras[CAMERA].onvif.autotracking
|
||||
autotracking.zooming = ZoomingModeEnum.absolute
|
||||
autotracking.calibrate_on_startup = True
|
||||
metrics = tracker.ptz_metrics[CAMERA]
|
||||
|
||||
tracker.stop_event = MagicMock()
|
||||
tracker.stop_event.is_set.side_effect = [False, True]
|
||||
tracker.move_queues = {CAMERA: asyncio.Queue()}
|
||||
tracker.move_queue_locks = {CAMERA: asyncio.Lock()}
|
||||
tracker.intercept = {CAMERA: 0.5}
|
||||
tracker.move_metrics = {CAMERA: []}
|
||||
tracker._wait_until_stopped = AsyncMock()
|
||||
tracker._calculate_move_coefficients = MagicMock()
|
||||
|
||||
async def move_relative(*_args) -> None:
|
||||
metrics.start_time.value = 100.0
|
||||
metrics.stop_time.value = 102.0
|
||||
|
||||
async def zoom_absolute(*_args) -> None:
|
||||
metrics.start_time.value = 103.0
|
||||
metrics.stop_time.value = 0
|
||||
|
||||
tracker.onvif._move_relative = AsyncMock(side_effect=move_relative)
|
||||
tracker.onvif._zoom_absolute = AsyncMock(side_effect=zoom_absolute)
|
||||
|
||||
tracker.move_queues[CAMERA].put_nowait((50.0, 0.5, 0.5, 0.5))
|
||||
await tracker._process_move_queue(CAMERA)
|
||||
|
||||
tracker.onvif._zoom_absolute.assert_awaited_once()
|
||||
self.assertEqual(
|
||||
tracker.move_metrics[CAMERA],
|
||||
[
|
||||
{
|
||||
"pan": 0.5,
|
||||
"tilt": 0.5,
|
||||
"start_timestamp": 100.0,
|
||||
"end_timestamp": 102.0,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestMaxTargetBox(unittest.TestCase):
|
||||
def test_follows_zoom_factor(self) -> None:
|
||||
self.assertAlmostEqual(calculate_max_target_box(0.5), 0.6**2)
|
||||
|
||||
@@ -321,6 +321,36 @@ class TestLighterANE(unittest.TestCase):
|
||||
ane.assert_not_called()
|
||||
|
||||
|
||||
class TestTensorRTProvider(unittest.TestCase):
|
||||
def _trt_options(self, env):
|
||||
with (
|
||||
patch.object(
|
||||
model_util.ort,
|
||||
"get_available_providers",
|
||||
return_value=["TensorrtExecutionProvider", "CPUExecutionProvider"],
|
||||
),
|
||||
patch.object(model_util.os, "makedirs"),
|
||||
patch.dict(os.environ, env, clear=False),
|
||||
):
|
||||
if "USE_FP16" not in env:
|
||||
os.environ.pop("USE_FP16", None)
|
||||
providers, options = model_util.get_ort_providers(device="Tensorrt")
|
||||
|
||||
return options[providers.index("TensorrtExecutionProvider")]
|
||||
|
||||
def test_fp16_is_disabled_by_default(self):
|
||||
self.assertFalse(self._trt_options({})["trt_fp16_enable"])
|
||||
|
||||
def test_use_fp16_enables_fp16(self):
|
||||
for value in ("true", "True", "TRUE"):
|
||||
with self.subTest(value=value):
|
||||
options = self._trt_options({"USE_FP16": value})
|
||||
self.assertTrue(options["trt_fp16_enable"])
|
||||
|
||||
def test_use_fp16_false_keeps_fp16_disabled(self):
|
||||
self.assertFalse(self._trt_options({"USE_FP16": "False"})["trt_fp16_enable"])
|
||||
|
||||
|
||||
class TestMIGraphX(unittest.TestCase):
|
||||
"""AMD's MIGraphX plugin provider, selected through its devices in the ROCm image."""
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.motion,
|
||||
CameraConfigUpdateEnum.objects,
|
||||
CameraConfigUpdateEnum.refresh,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.timestamp_style,
|
||||
CameraConfigUpdateEnum.zones,
|
||||
@@ -784,6 +785,21 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity)
|
||||
|
||||
# a replay camera recycled onto another scene's model needs new state
|
||||
for camera in updated_topics.get("refresh", []):
|
||||
camera_state = self.camera_states.get(camera)
|
||||
|
||||
if (
|
||||
camera_state is None
|
||||
or camera_state.model is self.config.model_for_camera(camera)
|
||||
):
|
||||
continue
|
||||
|
||||
self.force_end_all_events(camera, camera_state)
|
||||
camera_state.shutdown()
|
||||
self.frame_manager.close_camera(camera)
|
||||
self.create_camera_state(camera)
|
||||
|
||||
# manage camera disabled state
|
||||
for camera, config in self.config.cameras.items():
|
||||
if not config.enabled_in_config:
|
||||
|
||||
+33
-18
@@ -1,6 +1,8 @@
|
||||
"""configuration utils."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import errno
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
@@ -212,6 +214,28 @@ def find_config_file() -> str:
|
||||
return config_path
|
||||
|
||||
|
||||
def _write_config(config_file: str, yaml: YAML, config: dict[str, Any]) -> None:
|
||||
config_file = os.path.realpath(config_file)
|
||||
st = os.stat(config_file)
|
||||
tmp_file = f"{config_file}.tmp"
|
||||
with open(tmp_file, "w") as f:
|
||||
yaml.dump(config, f)
|
||||
os.chmod(tmp_file, st.st_mode)
|
||||
|
||||
with contextlib.suppress(PermissionError):
|
||||
os.chown(tmp_file, st.st_uid, st.st_gid)
|
||||
|
||||
try:
|
||||
os.replace(tmp_file, config_file)
|
||||
except OSError as e:
|
||||
if e.errno != errno.EBUSY:
|
||||
raise
|
||||
|
||||
# a file bind mount can't be renamed over, so write it in place
|
||||
shutil.copyfile(tmp_file, config_file)
|
||||
os.remove(tmp_file)
|
||||
|
||||
|
||||
def migrate_frigate_config(config_file: str):
|
||||
"""handle migrating the frigate config."""
|
||||
logger.info("Checking if frigate config needs migration...")
|
||||
@@ -259,8 +283,7 @@ def migrate_frigate_config(config_file: str):
|
||||
if previous_version < "0.14":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
|
||||
new_config = migrate_014(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.14"
|
||||
|
||||
logger.info("Migrating export file names...")
|
||||
@@ -277,56 +300,48 @@ def migrate_frigate_config(config_file: str):
|
||||
if previous_version < "0.15-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.15-0...")
|
||||
new_config = migrate_015_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.15-0"
|
||||
|
||||
if previous_version < "0.15-1":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.15-1...")
|
||||
new_config = migrate_015_1(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.15-1"
|
||||
|
||||
if previous_version < "0.16-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.16-0...")
|
||||
new_config = migrate_016_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.16-0"
|
||||
|
||||
if previous_version < "0.17-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.17-0...")
|
||||
new_config = migrate_017_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.17-0"
|
||||
|
||||
if previous_version < "0.18-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.18-0...")
|
||||
new_config = migrate_018_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.18-0"
|
||||
|
||||
if previous_version < "0.19-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.19-0...")
|
||||
new_config = migrate_019_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.19-0"
|
||||
|
||||
if needs_models:
|
||||
logger.info("Migrating frigate detectors and model to models...")
|
||||
new_config = migrate_models(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
|
||||
if needs_detector_rename:
|
||||
logger.info("Migrating renamed frigate detectors...")
|
||||
new_config = rename_hailo_detector(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
|
||||
logger.info("Finished frigate config migration...")
|
||||
|
||||
|
||||
@@ -318,7 +318,6 @@ def post_process_yolox(
|
||||
def get_ort_providers(
|
||||
force_cpu: bool = False,
|
||||
device: str | None = "AUTO",
|
||||
requires_fp16: bool = False,
|
||||
) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
if force_cpu:
|
||||
return (
|
||||
@@ -357,8 +356,8 @@ def get_ort_providers(
|
||||
options.append(
|
||||
{
|
||||
"device_id": device_id,
|
||||
"trt_fp16_enable": requires_fp16
|
||||
and os.environ.get("USE_FP16", "True") != "False",
|
||||
"trt_fp16_enable": os.environ.get("USE_FP16", "False").lower()
|
||||
== "true",
|
||||
"trt_timing_cache_enable": True,
|
||||
"trt_engine_cache_enable": True,
|
||||
"trt_timing_cache_path": os.path.join(
|
||||
|
||||
Generated
+32
-32
@@ -45,15 +45,15 @@
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^14.9.1",
|
||||
"framer-motion": "^14.10.0",
|
||||
"hls.js": "^1.7.4",
|
||||
"i18next": "^26.7.1",
|
||||
"i18next": "^26.7.2",
|
||||
"i18next-http-backend": "^4.0.10",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"js-yaml": "^5.5.0",
|
||||
"konva": "^10.9.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.69.0",
|
||||
"lucide-react": "^1.70.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
@@ -65,7 +65,7 @@
|
||||
"react-dropzone": "^20.1.4",
|
||||
"react-grid-layout": "^2.3.0",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"react-i18next": "^17.1.12",
|
||||
"react-i18next": "^17.1.13",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-konva": "^19.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -87,7 +87,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@playwright/test": "^1.65.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.27",
|
||||
"@types/node": "^26.6.6",
|
||||
@@ -1714,13 +1714,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz",
|
||||
"integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.64.1"
|
||||
"playwright": "1.65.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -5596,12 +5596,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "14.9.1",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.9.1.tgz",
|
||||
"integrity": "sha512-WDWNT2ixHdmpvGg02BZWI/SWwRYzPiMUf4aIkTBgrxxD8RYFdZO3fCM4pnLMo6j8mUpj0+BuJRHQBChI4v3Anw==",
|
||||
"version": "14.10.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.10.0.tgz",
|
||||
"integrity": "sha512-e8MjIet+IJKohYxBj8TAk9Mg5cD0HiOX87qde1YTPL67gI2ao074O6yBtQnIcWCk+bTX7XX4litepufLiYPrLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "14.9.1",
|
||||
"motion-dom": "14.10.0",
|
||||
"motion-utils": "14.7.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
@@ -6009,9 +6009,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.7.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.7.1.tgz",
|
||||
"integrity": "sha512-cX+3RRVczel/V2kCMTACckSGSpqgtWYEA6beXNYVms7dOQiHojdPYHD26lT4IKimNeoGWrLvtPgjftbRvmZJVw==",
|
||||
"version": "26.7.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.7.2.tgz",
|
||||
"integrity": "sha512-RsOY3BeaCDdkk52NeJSFAXELQ2rQXBO/tOub6L8ue2ICPfR/FRDWDOfEDg4xMPd1SqO5goVYQ6i72qWEUB/PPw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6996,9 +6996,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.69.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.69.0.tgz",
|
||||
"integrity": "sha512-UTFECm4yEgkgMN0kfLuxWcmlk+4u02Jrqehgk3ZXRx/Ud68OpP7hjkp67bvkBHmIpMaZCIveF1QDYeupO7XmYQ==",
|
||||
"version": "1.70.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.70.0.tgz",
|
||||
"integrity": "sha512-gCTPPN1MlQUBtUgdsUv+0CAUj+X8hifRUiRBw4E5v7lvDIOIpx0EZezdGQMSimS8pKA6esvullJub4GYr0mCHA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -8073,9 +8073,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "14.9.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.9.1.tgz",
|
||||
"integrity": "sha512-2eCCGXH2Yv09lM8GHK3TdfChrcvHxzVY19LD9VraoHEaQl5+PuGnTNnmJaBAR+5MdxUVdsLFXygFJjR9RwRSxw==",
|
||||
"version": "14.10.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.10.0.tgz",
|
||||
"integrity": "sha512-k0LwJZ1R5xCvqrScszO4ywuZP52lRcT3QO0IUovgczom9ZHb0BaRLOX6M2O40q7TrSLvzX/fIHHmDm8TUMt3fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "14.7.0"
|
||||
@@ -8537,13 +8537,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz",
|
||||
"integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.1"
|
||||
"playwright-core": "1.65.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -8553,9 +8553,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz",
|
||||
"integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -9077,9 +9077,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.1.12",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.12.tgz",
|
||||
"integrity": "sha512-Gf8riCKF/EmqQ8I1g0N5pJOLvJCkAXGAEkzOuRNHc/sBGL3dfE3WZ7+sDLrUo/kw/xdug3g48jo7M+s/SKf0HQ==",
|
||||
"version": "17.1.13",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.13.tgz",
|
||||
"integrity": "sha512-ZxhOSlyKSKPtbUNPLwYMmE3w3bhoc0jaZJZ6bc7wGOT6cLbrbs//6m01HxCLtFdSjHvFpwFwoPhxxzIlHSs9cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
|
||||
+5
-5
@@ -57,15 +57,15 @@
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^14.9.1",
|
||||
"framer-motion": "^14.10.0",
|
||||
"hls.js": "^1.7.4",
|
||||
"i18next": "^26.7.1",
|
||||
"i18next": "^26.7.2",
|
||||
"i18next-http-backend": "^4.0.10",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"js-yaml": "^5.5.0",
|
||||
"konva": "^10.9.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.69.0",
|
||||
"lucide-react": "^1.70.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
@@ -77,7 +77,7 @@
|
||||
"react-dropzone": "^20.1.4",
|
||||
"react-grid-layout": "^2.3.0",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"react-i18next": "^17.1.12",
|
||||
"react-i18next": "^17.1.13",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-konva": "^19.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@playwright/test": "^1.65.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.27",
|
||||
"@types/node": "^26.6.6",
|
||||
|
||||
@@ -1285,8 +1285,13 @@
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "Frigate+ API Key",
|
||||
"validated": "Frigate+ API key is detected and validated",
|
||||
"notValidated": "Frigate+ API key is not detected or not validated",
|
||||
"formatValid": "Frigate+ API key is detected and matches the expected format",
|
||||
"formatInvalid": "Frigate+ API key is not detected or does not match the expected format",
|
||||
"validate": "Validate with Frigate+",
|
||||
"validating": "Validating…",
|
||||
"serverValid": "Frigate+ accepted this API key",
|
||||
"serverInvalid": "Frigate+ rejected this API key",
|
||||
"serverUnreachable": "Unable to reach Frigate+ to validate this API key",
|
||||
"desc": "The Frigate+ API key enables integration with the Frigate+ service.",
|
||||
"plusLink": "Read more about Frigate+"
|
||||
},
|
||||
|
||||
@@ -240,14 +240,14 @@ const detect: SectionConfigOverrides = {
|
||||
},
|
||||
replay: {
|
||||
restartRequired: [],
|
||||
fieldOrder: ["width", "height", "fps"],
|
||||
fieldOrder: ["scene", "width", "height", "fps"],
|
||||
fieldGroups: {
|
||||
resolution: ["width", "height", "fps"],
|
||||
},
|
||||
hiddenFields: [
|
||||
"enabled",
|
||||
"enabled_in_config",
|
||||
"scene",
|
||||
hideSceneWithoutChoice,
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
|
||||
@@ -150,17 +150,18 @@ export function TrackingDetails({
|
||||
|
||||
// Fetch the VOD manifest JSON to get the actual clipFrom after keyframe
|
||||
// snapping. The backend may snap clipFrom backwards to a keyframe, making
|
||||
// the video start earlier than the requested time.
|
||||
// the video start earlier than the requested time. Uses sourceOffsetRef so
|
||||
// the manifest describes the same clip as videoSource.
|
||||
const vodManifestUrl = useMemo(() => {
|
||||
if (!event.camera) return null;
|
||||
const startTime =
|
||||
event.start_time + annotationOffset / 1000 - REVIEW_PADDING;
|
||||
const sourceOffset = sourceOffsetRef.current;
|
||||
const startTime = event.start_time + sourceOffset / 1000 - REVIEW_PADDING;
|
||||
const endTime =
|
||||
(event.end_time ?? Date.now() / 1000) +
|
||||
annotationOffset / 1000 +
|
||||
sourceOffset / 1000 +
|
||||
REVIEW_PADDING;
|
||||
return `vod/clip/${event.camera}/start/${startTime}/end/${endTime}`;
|
||||
}, [event, annotationOffset]);
|
||||
}, [event]);
|
||||
|
||||
const { data: vodManifest } = useSWR<VodManifest>(vodManifestUrl, null, {
|
||||
revalidateOnFocus: false,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import axios from "axios";
|
||||
import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -18,12 +19,34 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
|
||||
import { getPrimaryModel } from "@/utils/modelUtil";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
|
||||
type PlusKeyStatus = "valid" | "invalid" | "unreachable" | "not_set";
|
||||
|
||||
type PlusValidateResponse = { success: boolean; status: PlusKeyStatus };
|
||||
|
||||
export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [keyStatus, setKeyStatus] = useState<PlusKeyStatus>();
|
||||
|
||||
const validateKey = useCallback(async () => {
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
const response = await axios.post<PlusValidateResponse>("plus/validate");
|
||||
setKeyStatus(response.data.status);
|
||||
} catch {
|
||||
setKeyStatus("unreachable");
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.frigatePlus");
|
||||
@@ -68,17 +91,55 @@ export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
||||
</>
|
||||
}
|
||||
content={
|
||||
<div className="flex items-center gap-2">
|
||||
{config?.plus?.enabled ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{config?.plus?.enabled ? (
|
||||
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="size-5 shrink-0 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{config?.plus?.enabled
|
||||
? t("frigatePlus.apiKey.formatValid")
|
||||
: t("frigatePlus.apiKey.formatInvalid")}
|
||||
</span>
|
||||
</div>
|
||||
{config?.plus?.enabled && isAdmin && (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isValidating}
|
||||
onClick={validateKey}
|
||||
aria-label={t("frigatePlus.apiKey.validate")}
|
||||
>
|
||||
{isValidating && (
|
||||
<ActivityIndicator className="mr-2" size={16} />
|
||||
)}
|
||||
{isValidating
|
||||
? t("frigatePlus.apiKey.validating")
|
||||
: t("frigatePlus.apiKey.validate")}
|
||||
</Button>
|
||||
{keyStatus && !isValidating && (
|
||||
<div className="flex items-center gap-2">
|
||||
{keyStatus === "valid" ? (
|
||||
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
|
||||
) : keyStatus === "unreachable" ? (
|
||||
<AlertTriangle className="size-5 shrink-0 text-yellow-500" />
|
||||
) : (
|
||||
<XCircle className="size-5 shrink-0 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{keyStatus === "valid"
|
||||
? t("frigatePlus.apiKey.serverValid")
|
||||
: keyStatus === "unreachable"
|
||||
? t("frigatePlus.apiKey.serverUnreachable")
|
||||
: t("frigatePlus.apiKey.serverInvalid")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{config?.plus?.enabled
|
||||
? t("frigatePlus.apiKey.validated")
|
||||
: t("frigatePlus.apiKey.notValidated")}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user