Compare commits

...
Author SHA1 Message Date
Josh Hawkins 5ad1190c53 match a camera's frame names exactly in close_camera
The prefix match on `{camera}_frame` also caught another camera whose name starts with it, so removing `cam` dropped the cached ring of a camera named `cam_frame`.
2026-10-12 09:23:38 -05:00
Josh Hawkins 641a4ee0c6 drop a camera's cached frame mappings when it is removed or recreated
A reader that kept its mappings open stayed on a removed camera's segments, which pinned their shared memory until restart, and after a same-size recreate the ledger reopen only refreshed the slot being read while the other cached slots still returned the old camera's pixels. Readers now drop every cached slot for a camera on its remove update, and get() does the same when a ledger mismatch turns out to be a replaced ledger rather than a lapped slot.
2026-10-12 09:15:37 -05:00
Josh Hawkins ef540d88cb bring the FrameManager interface in line with its implementation
The abstract get() still had a timeout_ms signature from an older implementation and frame_times() wasn't declared, though capture and detect are typed against the interface. get() also didn't say how long the array it returns stays valid.
2026-10-12 06:15:26 -05:00
Josh Hawkins 867dd95ac6 log when a frame slot was overwritten before it was read
A lapped read and a missing segment ended up as the same debug line at every caller, so there was no way to tell a slow reader from a missing frame.
2026-10-11 23:01:33 -05:00
Josh Hawkins d807bafd49 verify the frame for embeddings object updates
Tracked object events only carried the object's own frame_time, which stays at its last detection when the object isn't detected in a frame, so embeddings couldn't check the slot it read and could crop face or plate data from a frame that had already been overwritten. Events now carry the time of the frame they name, and embeddings passes it to `get()`.
2026-10-11 23:00:46 -05:00
Josh Hawkins 91fbde81a0 keep frame shm open and skip overwritten frames
Every reader closed and reopened a frame's shared memory segment on each frame, so each process paid a page fault on every page of a fresh mapping (about 760 for a 1080p frame) per frame. Readers now keep segments mapped. Capture stamps each slot's frame time in a per-camera `{camera}_frame_times` segment, and `get()` returns None when the slot no longer holds the requested frame, reopening once by name so a mapping left over from a same-size camera recreate heals itself. Capture also reuses a slot when the frame queue is full, since no reader was told about it.
2026-10-11 23:00:46 -05:00
Josh HawkinsandGitHub bc06b8c753 run opencv single threaded in camera tracking processes (#24640)
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
OpenCV's default thread pool is sized to every core, and its idle threads spin while it works on the small motion frames and detection regions in each camera tracking process. On a 16 thread machine replaying 720p clips at 5 fps, tracking CPU dropped from 27-50 ms to 5-8 ms per frame with identical output and no change in wall time. Frames from ffmpeg are now also read directly into shared memory with readinto(), which skips one copy per frame.
2026-10-11 21:01:58 -06:00
e7164882f5 Return to the requested URL after logging in (#24637)
When an unauthenticated user opens a deep link, carry the requested
path through the login page in a redirect query param and return to it
after a successful login instead of always going to the home page.
Only same-origin targets are honored.


Claude-Session: https://claude.ai/code/session_011CFGjgYURSRTpXBaxp1Dq7

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:59:13 -06:00
Martin WeineltandGitHub 135a95d8f2 Fix various warnings and deprecations (#23641)
* Migrate to fastapi lifespan for startup events

The `on_event` style decorator is deprecated

https://fastapi.tiangolo.com/advanced/events/

* Migrate to pydantic TypeAdapter

https://pydantic.dev/docs/validation/2.12/get-started/migration/#introduction-of-typeadapter

* Replace pytz with native tz-aware objects and zoneinfo library

Supported from Python 3.9, see https://peps.python.org/pep-0615/. Also
https://blog.ganssle.io/articles/2019/11/utcnow.html.

Also provide tzdata as new dependency to supplement systems without local
tzdata information.

* Provide lookup for normalized timezone names

This provides a helper to look up the correct timezone name independent
of the casing of the given timezone name.

* Test timezone naming normalization and error handling
2026-10-11 17:49:33 -05:00
Josh HawkinsandGitHub 186a730790 use CLIPImageProcessorPil to silence transformers torchvision warning (#24636)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-10-11 13:21:21 -06:00
Josh HawkinsandGitHub 5c32af3c7f Autotracking improvements and fixes (#24633)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* fix autotracking tracker selection and crop before histogram conversion

- Rebuild the norfair trackers when an onvif save changes autotracking enabled_in_config or the PTZ-tracked labels. The trackers were only built at startup, so disabling autotracking or removing person from its track list in the UI made get_tracker pick a tracker that didn't exist, and the KeyError killed the camera process until restart. Saves that leave the selection alone, and runtime MQTT toggles, don't touch tracking.
- get_tracker now returns the default tracker that match_and_update actually feeds. On autotracking cameras it returned the static default for labels without their own tracker, so register missed the norfair object and lost the pre-initialization score history, and deregister pruned the wrong tracker.
- get_histogram crops the box out of each I420 plane before converting to BGR instead of converting the whole frame. It runs per detection per frame on autotracking cameras and now costs about 0.2 ms at any resolution, down from 1 ms at 1080p and 5 ms at 4K.

* don't rebuild trackers for track list edits while autotracking is disabled
2026-10-11 07:02:42 -06:00
Josh HawkinsandGitHub acc84d7658 Fixes (#24631)
* fix classification wizard finding no sample images on large databases

The wizard grouped tracked objects by camera and 6 hour block, oldest first, then kept the first 100. With more than 100 groups that was always the oldest objects, which often have no snapshot or thumbnail left on disk, so no examples were generated. Shuffle the selection before truncating, and keep extracting from the remaining tracked objects until 100 usable images are found.

* add notice and system ui message when a camera isn't using go2rtc

* add docs note about privacy masks

* match index-less device strings to hardware units in the detection models picker

A config with `edgetpu:usb` was reported as hardware that wasn't found, because the picker compared device strings exactly and the probe reports the unit as `edgetpu:usb:0`. A device without an index now resolves to the first unit of that kind, so the hardware dropdown, the unit checkboxes, and the model summary all recognize it.

* don't treat unknown audio as an audio-bearing stream

A recording row with NULL has_audio (ffprobe failed and the cv2 fallback can't report audio) marked the whole stream as audio-bearing, so every confirmed video-only row on it was dropped as a glitch. A stream now counts as audio-bearing only when a row is known to carry audio.

* stage main+sub exports on disk instead of /tmp/cache

A main+sub export stages a full copy of itself before the final file is written. That copy went to /tmp/cache, so a long export outgrew the tmpfs and failed with no space left on device. Staged runs are now written to the exports directory, and startup removes any left behind by a killed export.

* address review feedback

- stage main+sub export runs in a staging subfolder of the exports directory, so media sync can't delete them mid-export and startup cleanup can't remove a finished export with a matching name or fail on an unremovable file
- run object classification example collection off the event loop
- prefer an exact hardware unit match over an index-less one, so an AMD GPU's "onnx" no longer selects the NVIDIA entry
- add tests for the classification fallback and index-less hardware matching

* serialize example collection and clean up exports when staging dir creation fails

- overlapping object example requests could delete each other's images in the shared temp and train directories, so collection now runs under a lock
- a failed makedirs for the staging directory raised past the failed-export cleanup and left the export spinning until restart, so it now fails the run through the normal cleanup path

* revert
2026-10-11 07:01:39 -06:00
Josh HawkinsandGitHub 2cb03f442c Group dependabot updates (#24624)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* group dependabot updates and hold known-bad versions

Dependabot opened a separate PR for every bump, refilled the list with security PRs right after each merge, and kept proposing versions known to break Frigate.

- Group minor and patch updates and security updates into one PR per ecosystem, with separate groups for packages that only resolve together
- Move every version hold into ignore rules, each with its reason
- Switch to weekly updates with a 3-day cooldown
- Turn off version updates for docker/tensorrt, since no pull request check builds those images
- Drop target-branch, which kept these options from applying to security updates, and the docker entry, which never opened a PR
- Scan the setup composite action, and bump change-string-case, setup-qemu, setup-buildx and login-action

* group github actions security updates
2026-10-10 15:08:01 -05:00
61 changed files with 1468 additions and 256 deletions
+4 -4
View File
@@ -34,15 +34,15 @@ runs:
run: sudo systemctl start docker
shell: bash
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v5
uses: ASzc/change-string-case-action@v8
with:
string: ${{ github.repository }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to the Container registry
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
+139 -22
View File
@@ -1,40 +1,157 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
# Local composite actions are only scanned when their directory is listed
directories:
- "/"
- "/.github/actions/setup"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
- package-ecosystem: "docker"
directory: "/docker"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
actions:
patterns: ["*"]
actions-security:
applies-to: security-updates
patterns: ["*"]
- package-ecosystem: "pip"
directory: "/docker/main"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
# Sets that fail pip resolution unless they move together
peewee:
patterns: ["peewee", "peewee-migrate"]
crypto:
patterns: ["cryptography", "joserfc", "pywebpush", "py-vapid"]
pydantic-genai:
patterns: ["pydantic", "google-genai"]
pip-main-minor-patch:
patterns: ["*"]
update-types: ["minor", "patch"]
pip-main-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# numpy 2.5 needs Python 3.12; norfair, tflite_runtime and rknn-toolkit2
# cap numpy below 2
- dependency-name: "numpy"
versions: [">=2"]
# Every OpenCV wheel from 4.12 requires numpy 2
- dependency-name: "opencv-python-headless"
versions: [">=4.12"]
- dependency-name: "opencv-contrib-python"
versions: [">=4.12"]
# aarch64 only. Rockchip rknn-toolkit2 caps protobuf at 4.25.4 and
# TensorFlow 2.21 needs protobuf 6.31 or newer.
- dependency-name: "tensorflow"
versions: [">=2.20"]
# Needs Python 3.12 and numpy 2
- dependency-name: "librosa"
versions: [">=1.0"]
# Needs Python 3.12
- dependency-name: "scipy"
versions: [">=1.18"]
# Newer stubs report false errors against peewee 4
- dependency-name: "types-peewee"
versions: [">=4.1"]
# sdist only and imported by HailoRT; needs a Hailo device to test
- dependency-name: "netifaces"
versions: [">=0.11"]
- package-ecosystem: "pip"
directory: "/docker/tensorrt"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
interval: weekly
# No pull request check builds these images, so version updates are off.
# Security updates still open.
open-pull-requests-limit: 0
groups:
pip-trt-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# onnx 1.17 and newer write a newer IR and opset than TensorRT 8.6 on
# JetPack 6 is known to parse
- dependency-name: "onnx"
versions: [">=1.17"]
# 1.27 moved to CUDA 13, and the image ships the CUDA 12.8 wheel set
- dependency-name: "onnxruntime-gpu"
versions: [">=1.27"]
# Hand-matched CUDA 12.8 set (2c9a25e67); move as a block
- dependency-name: "nvidia-*"
# Must match the JetPack CUDA runtime
- dependency-name: "cuda-python"
versions: [">=12.9"]
- package-ecosystem: "npm"
directory: "/web"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
radix:
patterns: ["@radix-ui/*"]
rjsf:
patterns: ["@rjsf/*"]
web-minor-patch:
patterns: ["*"]
update-types: ["minor", "patch"]
web-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# typescript-eslint peers typescript below 6.1
- dependency-name: "typescript"
versions: [">=6.1"]
# Tailwind 4 is a config rewrite; scrollbar 4 and merge 3 depend on it
- dependency-name: "tailwindcss"
update-types: ["version-update:semver-major"]
- dependency-name: "tailwind-scrollbar"
update-types: ["version-update:semver-major"]
- dependency-name: "tailwind-merge"
update-types: ["version-update:semver-major"]
# Intermittent failure in the UI settings import spec, cause not found
- dependency-name: "zod"
update-types: ["version-update:semver-major"]
# Needs an on-device pinch test before moving
- dependency-name: "react-zoom-pan-pinch"
update-types: ["version-update:semver-major"]
# Build fails until monaco-worker-manager releases its PR 4
- dependency-name: "monaco-editor"
versions: [">=0.55.0"]
# 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"
schedule:
interval: daily
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
allow:
- dependency-name: "@docusaurus/*"
open-pull-requests-limit: 10
target-branch: dev
groups:
docusaurus:
patterns: ["@docusaurus/*"]
docs-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# A Docusaurus major needs the openapi plugin and theme moved in the
# same commit
- dependency-name: "@docusaurus/*"
update-types: ["version-update:semver-major"]
+1 -1
View File
@@ -586,7 +586,7 @@ jobs:
- arm64_build
steps:
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v6
uses: ASzc/change-string-case-action@v8
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
with:
persist-credentials: false
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v6
uses: ASzc/change-string-case-action@v8
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
+1 -1
View File
@@ -25,7 +25,7 @@ peewee_migrate == 2.3.*
psutil == 7.2.*
pydantic == 2.13.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
pytz == 2026.*
tzdata == 2026.5
pyzmq == 27.2.*
ruamel.yaml == 0.19.*
tzlocal == 5.4.4
+8
View File
@@ -53,6 +53,14 @@ go2rtc:
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
### Can I add a privacy mask to hide part of my camera's view?
Frigate does not have privacy masks. [Motion masks and object filter masks](../configuration/masks.md) only affect detection, they don't hide anything in live view, recordings, or snapshots.
Privacy masks are best configured in the camera's firmware settings so the area is blacked out before the video ever leaves the camera and no extra processing is needed. Check there first.
If your camera does not support privacy masks, there is no efficient alternative. Frigate copies the camera's video into recordings and live view without decoding it, so part of the image can't be hidden without transcoding (re-encoding) the stream. This can be done with a custom ffmpeg filter in go2rtc, but it is not recommended. Every masked camera needs a continuous re-encode, which significantly increases CPU usage, especially for high resolution streams.
### My mjpeg stream or snapshots look green and crazy
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
+7 -7
View File
@@ -2131,7 +2131,7 @@ paths:
required: false
schema:
type: string
default: utc
default: UTC
title: Timezone
responses:
'200':
@@ -3266,7 +3266,7 @@ paths:
anyOf:
- type: string
- type: 'null'
default: utc
default: UTC
title: Timezone
responses:
'200':
@@ -4666,7 +4666,7 @@ paths:
anyOf:
- type: string
- type: 'null'
default: utc
default: UTC
title: Timezone
responses:
'200':
@@ -4928,7 +4928,7 @@ paths:
anyOf:
- type: string
- type: 'null'
default: utc
default: UTC
title: Timezone
- name: min_score
in: query
@@ -5008,7 +5008,7 @@ paths:
anyOf:
- type: string
- type: 'null'
default: utc
default: UTC
title: Timezone
- name: has_clip
in: query
@@ -7367,7 +7367,7 @@ paths:
required: false
schema:
type: string
default: utc
default: UTC
title: Timezone
- name: cameras
in: query
@@ -7417,7 +7417,7 @@ paths:
required: false
schema:
type: string
default: utc
default: UTC
title: Timezone
responses:
'200':
+8 -1
View File
@@ -1,5 +1,6 @@
"""Object classification APIs."""
import asyncio
import datetime
import logging
import os
@@ -48,6 +49,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.classification])
# concurrent collections for one model share its temp and train directories
example_collection_lock = asyncio.Lock()
def invalid_name_response(value: str) -> JSONResponse:
"""Response for a name that cannot be used as a path component."""
@@ -1290,7 +1294,10 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
if model_name is None:
return invalid_name_response(body.model_name)
collect_object_classification_examples(model_name, body.label)
async with example_collection_lock:
await asyncio.to_thread(
collect_object_classification_examples, model_name, body.label
)
return JSONResponse(
content={"success": True, "message": "Example generation completed"},
@@ -7,4 +7,4 @@ class AppTimelineHourlyQueryParameters(BaseModel):
after: float | None = None
before: float | None = None
limit: int | None = 200
timezone: str | None = "utc"
timezone: str | None = "UTC"
@@ -40,7 +40,7 @@ class EventsQueryParams(BaseModel):
max_length: float | None = None
event_id: str | None = None
sort: str | None = None
timezone: str | None = "utc"
timezone: str | None = "UTC"
class EventsSearchQueryParams(BaseModel):
@@ -68,7 +68,7 @@ class EventsSearchQueryParams(BaseModel):
has_clip: bool | None = None
has_snapshot: bool | None = None
is_submitted: bool | None = None
timezone: str | None = "utc"
timezone: str | None = "UTC"
min_score: float | None = None
max_score: float | None = None
min_speed: float | None = None
@@ -78,6 +78,6 @@ class EventsSearchQueryParams(BaseModel):
class EventsSummaryQueryParams(BaseModel):
timezone: str | None = "utc"
timezone: str | None = "UTC"
has_clip: int | None = None
has_snapshot: int | None = None
@@ -3,7 +3,7 @@ from pydantic.json_schema import SkipJsonSchema
class MediaRecordingsSummaryQueryParams(BaseModel):
timezone: str = "utc"
timezone: str = "UTC"
cameras: str | None = "all"
@@ -19,7 +19,7 @@ class ReviewSummaryQueryParams(BaseModel):
cameras: str = "all"
labels: str = "all"
zones: str = "all"
timezone: str = "utc"
timezone: str = "UTC"
class ReviewActivityMotionQueryParams(BaseModel):
+12 -9
View File
@@ -1,6 +1,7 @@
import asyncio
import logging
import re
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
@@ -81,9 +82,20 @@ def create_fastapi_app(
config_holder: ConfigHolder | None = None,
notice_registry: NoticeRegistry | None = None,
):
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("FastAPI started")
asyncio.create_task(
debug_replay_auto_stop_watchdog(
replay_manager, frigate_config, config_publisher
)
)
yield
logger.info("Starting FastAPI app")
app = FastAPI(
debug=False,
lifespan=lifespan,
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
dependencies=[Depends(require_admin_by_default())]
if enforce_default_admin
@@ -119,15 +131,6 @@ def create_fastapi_app(
database.close()
return response
@app.on_event("startup")
async def startup():
logger.info("FastAPI started")
asyncio.create_task(
debug_replay_auto_stop_watchdog(
replay_manager, frigate_config, config_publisher
)
)
# Rate limiter (used for login endpoint)
if frigate_config.auth.failed_login_rate_limit is None:
limiter.enabled = False
+5 -2
View File
@@ -14,10 +14,10 @@ from enum import Enum
from pathlib import Path as FilePath
from typing import IO, Any
from urllib.parse import unquote
from zoneinfo import ZoneInfo
import cv2
import numpy as np
import pytz
from fastapi import APIRouter, Depends, Path, Query, Request, Response
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pathvalidate import sanitize_filename
@@ -64,6 +64,7 @@ from frigate.util.recording_coverage import (
plan_clip,
resolve_coverage,
)
from frigate.util.time import get_normalized_tz_name
logger = logging.getLogger(__name__)
@@ -837,7 +838,9 @@ async def vod_hour(
parts = year_month.split("-")
start_date = (
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC)
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
- datetime.now(
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
).utcoffset()
)
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp()
+5 -2
View File
@@ -5,8 +5,8 @@ import logging
import os
import threading
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
import pytz
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
@@ -22,6 +22,7 @@ from frigate.api.defs.response.preview_response import (
from frigate.api.defs.tags import Tags
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
from frigate.models import Previews
from frigate.util.time import get_normalized_tz_name
logger = logging.getLogger(__name__)
@@ -126,7 +127,9 @@ def preview_hour(
parts = year_month.split("-")
start_date = (
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC)
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
- datetime.now(
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
).utcoffset()
)
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp()
+1 -1
View File
@@ -175,7 +175,7 @@ async def all_recordings_summary(
@router.get(
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
)
async def recordings_summary(camera_name: str, timezone: str = "utc"):
async def recordings_summary(camera_name: str, timezone: str = "UTC"):
"""Returns hourly summary for recordings of given camera"""
time_range_query = (
+4
View File
@@ -169,6 +169,10 @@ class CameraMaintainer(threading.Thread):
frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1]
self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
# a leftover ledger from an unclean exit may be the wrong length
self.frame_manager.delete(f"{config.name}_frame_times")
self.frame_manager.create(f"{config.name}_frame_times", max(count, 1) * 8)
capture_process = CameraCapture(
config,
count,
+5 -8
View File
@@ -349,7 +349,7 @@ class CameraState:
return
current_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv
frame_name, self.camera_config.frame_shape_yuv, frame_time
)
tracked_objects = self.tracked_objects.copy()
@@ -404,7 +404,7 @@ class CameraState:
self.send_mqtt_snapshot(new_obj, object_type)
for c in self.callbacks["start"]:
c(self.name, new_obj, frame_name)
c(self.name, new_obj, frame_name, frame_time)
for id in updated_ids:
updated_obj = tracked_objects[id]
@@ -416,7 +416,7 @@ class CameraState:
if autotracker_update or significant_update:
for c in self.callbacks["autotrack"]:
c(self.name, updated_obj, frame_name)
c(self.name, updated_obj, frame_name, frame_time)
if thumb_update and current_frame is not None:
# ensure this frame is stored in the cache
@@ -466,7 +466,7 @@ class CameraState:
):
# call event handlers
for c in self.callbacks["update"]:
c(self.name, updated_obj, frame_name)
c(self.name, updated_obj, frame_name, frame_time)
updated_obj.last_published = frame_time
# send MQTT snapshot when object first enters a required zone,
@@ -488,7 +488,7 @@ class CameraState:
removed_obj.obj_data["end_time"] = frame_time
logger.debug(f"{self.name}: end callback for object {id}")
for c in self.callbacks["end"]:
c(self.name, removed_obj, frame_name)
c(self.name, removed_obj, frame_name, frame_time)
# TODO: can i switch to looking this up and only changing when an event ends?
# maintain best objects
@@ -603,9 +603,6 @@ class CameraState:
self.current_frame_time = frame_time
self._current_frame = np.copy(current_frame)
if self.previous_frame_id is not None:
self.frame_manager.close(self.previous_frame_id)
self.previous_frame_id = frame_name
def send_mqtt_snapshot(self, new_obj: TrackedObject, object_type: str) -> None:
+18 -2
View File
@@ -8,7 +8,16 @@ from .zmq_proxy import Publisher, Subscriber
class EventUpdatePublisher(
Publisher[tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]]]
Publisher[
tuple[
EventTypeEnum,
EventStateEnum,
str | None,
str,
dict[str, Any],
float | None,
]
]
):
"""Publishes events (objects, audio, manual)."""
@@ -19,7 +28,14 @@ class EventUpdatePublisher(
def publish(
self,
payload: tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]],
payload: tuple[
EventTypeEnum,
EventStateEnum,
str | None,
str,
dict[str, Any],
float | None,
],
sub_topic: str = "",
) -> None:
super().publish(payload, sub_topic)
+1
View File
@@ -8,6 +8,7 @@ MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache"
BASE_DIR = "/media/frigate"
CLIPS_DIR = f"{BASE_DIR}/clips"
EXPORT_DIR = f"{BASE_DIR}/exports"
EXPORT_STAGING_DIR = f"{EXPORT_DIR}/staging"
FACE_DIR = f"{CLIPS_DIR}/faces"
THUMB_DIR = f"{CLIPS_DIR}/thumbs"
RECORD_DIR = f"{BASE_DIR}/recordings"
+7 -8
View File
@@ -332,6 +332,9 @@ class EmbeddingMaintainer(threading.Thread):
"""Apply camera config updates and register newly enabled processors."""
updated_topics = self.config_updater.check_for_updates()
for camera in updated_topics.get("remove", []):
self.frame_manager.close_camera(camera)
if updated_topics.keys() & GENAI_UPDATE_TOPICS:
self._sync_genai_processors()
@@ -517,7 +520,7 @@ class EmbeddingMaintainer(threading.Thread):
if update is None:
return
source_type, event_type, camera, frame_name, data = update
source_type, event_type, camera, frame_name, data, frame_time = update
logger.debug(
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
@@ -546,7 +549,7 @@ class EmbeddingMaintainer(threading.Thread):
# Create our own thumbnail based on the bounding box and the frame time
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
frame_name, camera_config.frame_shape_yuv, frame_time
)
except FileNotFoundError:
logger.debug(f"Frame {frame_name} not found for camera {camera}")
@@ -583,8 +586,6 @@ class EmbeddingMaintainer(threading.Thread):
PostProcessDataEnum.tracked_object,
)
self.frame_manager.close(frame_name)
def _process_finalized(self) -> None:
"""Process the end of an event."""
while True:
@@ -793,7 +794,7 @@ class EmbeddingMaintainer(threading.Thread):
if topic is None:
return
camera, frame_name, _, _, motion_boxes, _ = data
camera, frame_name, frame_time, _, motion_boxes, _ = data
if not camera or camera not in self.config.cameras:
return
@@ -817,7 +818,7 @@ class EmbeddingMaintainer(threading.Thread):
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
frame_name, camera_config.frame_shape_yuv, frame_time
)
except FileNotFoundError:
pass
@@ -841,8 +842,6 @@ class EmbeddingMaintainer(threading.Thread):
{"camera": camera, "motion": motion_boxes}, yuv_frame
)
self.frame_manager.close(frame_name)
def _process_deferred_results(self) -> None:
"""Drain results from deferred processors and perform IPC side-effects."""
for processor in self.realtime_processors:
+2 -2
View File
@@ -5,7 +5,7 @@ import os
import shutil
import threading
from transformers import AutoTokenizer, CLIPImageProcessor
from transformers import AutoTokenizer, CLIPImageProcessorPil
from transformers.utils.logging import disable_progress_bar
from frigate.comms.inter_process import InterProcessRequestor
@@ -209,7 +209,7 @@ class JinaV1ImageEmbedding(BaseEmbedding):
if self.downloader:
self.downloader.wait_for_download()
self.feature_extractor = CLIPImageProcessor.from_pretrained(
self.feature_extractor = CLIPImageProcessorPil.from_pretrained(
f"{MODEL_CACHE_DIR}/{self.model_name}",
)
+1 -1
View File
@@ -94,7 +94,7 @@ class EventProcessor(threading.Thread):
if update == None:
continue
source_type, event_type, camera, _, event_data = update
source_type, event_type, camera, _, event_data, _ = update
logger.debug(
f"Event received: {source_type} {event_type} {camera} {event_data['id']}"
+5 -1
View File
@@ -2,6 +2,7 @@
import logging
import os
import shutil
import threading
import time
from collections.abc import Callable
@@ -15,7 +16,7 @@ from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.config.camera.record import ChaptersEnum
from frigate.const import UPDATE_JOB_STATE
from frigate.const import EXPORT_STAGING_DIR, UPDATE_JOB_STATE
from frigate.jobs.job import Job
from frigate.models import Export
from frigate.record.export import (
@@ -415,6 +416,9 @@ def reap_stale_exports() -> None:
this in a try/except. A failure on a single row will not stop the rest
of the sweep, and a failure in the top-level query will log and return.
"""
# staged stream runs live on disk, so a killed export leaves them behind
shutil.rmtree(EXPORT_STAGING_DIR, ignore_errors=True)
try:
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
except Exception:
-5
View File
@@ -170,7 +170,6 @@ class DetectorRunner(FrigateProcess):
mono_start = time.monotonic()
detections = object_detector.detect_raw(input_frame)
duration = time.monotonic() - mono_start
frame_manager.close(connection_id)
if connection_id not in self.outputs:
self.create_output_shm(connection_id)
@@ -259,10 +258,6 @@ class AsyncDetectorRunner(FrigateProcess):
ts = self.send_times.popleft()
duration = time.perf_counter() - ts
# release input buffer
assert self._frame_manager is not None
self._frame_manager.close(connection_id)
if connection_id not in self.outputs:
self.create_output_shm(connection_id)
+6 -18
View File
@@ -145,6 +145,7 @@ class OutputProcess(FrigateProcess):
CameraConfigUpdateEnum.birdseye,
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.record,
CameraConfigUpdateEnum.remove,
],
)
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
@@ -208,6 +209,10 @@ class OutputProcess(FrigateProcess):
birdseye,
)
if CameraConfigUpdateEnum.remove in updates:
for camera in updates["remove"]:
frame_manager.close_camera(camera)
_result = detection_subscriber.check_for_update(timeout=1)
if _result is None:
continue
@@ -241,7 +246,7 @@ class OutputProcess(FrigateProcess):
continue
frame = frame_manager.get(
frame_name, self.config.cameras[camera].frame_shape_yuv
frame_name, self.config.cameras[camera].frame_shape_yuv, frame_time
)
if frame is None:
@@ -296,8 +301,6 @@ class OutputProcess(FrigateProcess):
frame,
)
frame_manager.close(frame_name)
move_preview_frames("clips")
while True:
@@ -309,21 +312,6 @@ class OutputProcess(FrigateProcess):
if not topic or data is None:
break
(
camera,
frame_name,
frame_time,
current_tracked_objects,
motion_boxes,
regions,
) = data
camera_config = self.config.cameras.get(camera)
if camera_config is not None:
frame_manager.get(frame_name, camera_config.frame_shape_yuv)
frame_manager.close(frame_name)
detection_subscriber.stop()
for jsmpeg in jsmpeg_cameras.values():
+1 -3
View File
@@ -124,7 +124,7 @@ class PtzMotionEstimator:
)
yuv_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv
frame_name, self.camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
@@ -181,8 +181,6 @@ class PtzMotionEstimator:
except Exception:
pass
self.frame_manager.close(frame_name)
return self.coord_transformations
+19 -7
View File
@@ -15,8 +15,8 @@ from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytz
from pathvalidate import sanitize_filename
from peewee import DoesNotExist
@@ -26,6 +26,7 @@ from frigate.const import (
CACHE_DIR,
CLIPS_DIR,
EXPORT_DIR,
EXPORT_STAGING_DIR,
MAX_PLAYLIST_SECONDS,
PREVIEW_FRAME_TYPE,
STREAM_TYPE_MAIN,
@@ -48,7 +49,7 @@ from frigate.util.recording_coverage import (
stream_media_summary,
)
from frigate.util.services import get_video_properties
from frigate.util.time import is_current_hour
from frigate.util.time import get_normalized_tz_name, is_current_hour
logger = logging.getLogger(__name__)
@@ -559,7 +560,7 @@ class RecordingExporter(threading.Thread):
)
def _staged_run_path(self, index: int) -> str:
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4")
return os.path.join(EXPORT_STAGING_DIR, f"{self.export_id}_{index}.mp4")
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
"""Probe one recording from a run for its resolution.
@@ -714,6 +715,17 @@ class RecordingExporter(threading.Thread):
for index, run in enumerate(runs):
dest = self._staged_run_path(index)
try:
os.makedirs(os.path.dirname(dest), exist_ok=True)
except OSError:
logger.exception(
"Failed to create staging directory for export %s",
self.export_id,
)
self._cleanup_staged_runs()
return False
weight = 100.0 * run.duration / total_duration
base = 100.0 * completed / total_duration
@@ -862,8 +874,8 @@ class RecordingExporter(threading.Thread):
tz_name = self.config.ui.timezone
if tz_name:
try:
tz = pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
tz = ZoneInfo(get_normalized_tz_name(tz_name))
except (ValueError, ZoneInfoNotFoundError):
tz = None
if tz is not None:
return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime(
@@ -1026,8 +1038,8 @@ class RecordingExporter(threading.Thread):
tz: datetime.tzinfo | None = None
if tz_name:
try:
tz = pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
tz = ZoneInfo(get_normalized_tz_name(tz_name))
except (ValueError, ZoneInfoNotFoundError):
tz = None
if tz is None:
tz = datetime.UTC
+5 -8
View File
@@ -504,6 +504,7 @@ class ReviewSegmentMaintainer(threading.Thread):
def _handle_camera_removed(self, camera: str) -> None:
"""Close out a deleted camera's segment so a reused name cannot inherit it."""
self.forcibly_end_segment(camera)
self.frame_manager.close_camera(camera)
self.indefinite_events.pop(camera, None)
self.recent_classification_state_changes.pop(camera, None)
@@ -544,7 +545,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
frame_name, camera_config.frame_shape_yuv, frame_time
)
except FileNotFoundError:
return
@@ -554,7 +555,6 @@ class ReviewSegmentMaintainer(threading.Thread):
return
pending.update_frame(camera_config, yuv_frame, objects)
self.frame_manager.close(frame_name)
def update_existing_segment(
self,
@@ -653,7 +653,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try:
if should_update_image:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
frame_name, camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
@@ -669,7 +669,6 @@ class ReviewSegmentMaintainer(threading.Thread):
activity.get_all_objects(),
prev_data,
)
self.frame_manager.close(frame_name)
except FileNotFoundError:
return
@@ -677,7 +676,7 @@ class ReviewSegmentMaintainer(threading.Thread):
if not segment.has_frame:
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
frame_name, camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
@@ -685,7 +684,6 @@ class ReviewSegmentMaintainer(threading.Thread):
return
segment.save_full_frame(camera_config, yuv_frame)
self.frame_manager.close(frame_name)
self._publish_segment_update(
segment, camera_config, None, [], prev_data
)
@@ -770,7 +768,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
frame_name, camera_config.frame_shape_yuv, frame_time
)
if yuv_frame is None:
@@ -780,7 +778,6 @@ class ReviewSegmentMaintainer(threading.Thread):
new_segment.update_frame(
camera_config, yuv_frame, activity.get_all_objects()
)
self.frame_manager.close(frame_name)
self._publish_segment_start(new_segment)
except FileNotFoundError:
return
+17
View File
@@ -453,6 +453,23 @@ class TestHttpExport(BaseTestHttp):
assert unchanged.name == "front door export"
assert unchanged.video_path == video
def test_reap_stale_exports_removes_staged_runs(self):
with tempfile.TemporaryDirectory() as tmpdir:
staging_dir = os.path.join(tmpdir, "staging")
os.makedirs(staging_dir)
staged = os.path.join(staging_dir, "front_door_abc_0.mp4")
finished = os.path.join(tmpdir, "export_stage_saved_abc.mp4")
for path in (staged, finished):
with open(path, "w") as handle:
handle.write("video")
with patch("frigate.jobs.export.EXPORT_STAGING_DIR", staging_dir):
reap_stale_exports()
assert not os.path.exists(staging_dir)
assert os.path.exists(finished)
def test_reap_stale_exports_deletes_rows_with_no_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
stale_video = os.path.join(tmpdir, "stale.mp4")
+18 -21
View File
@@ -3,8 +3,8 @@
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
from zoneinfo import ZoneInfo
import pytz
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
@@ -128,16 +128,16 @@ class TestHttpMedia(BaseTestHttp):
In 2024, DST in America/New_York transitions on March 10, 2024 at 2:00 AM
Clocks spring forward from 2:00 AM to 3:00 AM (EST to EDT)
"""
tz = pytz.timezone("America/New_York")
tz = ZoneInfo("America/New_York")
# March 9, 2024 at 12:00 PM EST (before DST)
march_9_noon = tz.localize(datetime(2024, 3, 9, 12, 0, 0)).timestamp()
march_9_noon = datetime(2024, 3, 9, 12, 0, 0, tzinfo=tz).timestamp()
# March 10, 2024 at 12:00 PM EDT (after DST transition)
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
# March 11, 2024 at 12:00 PM EDT (after DST)
march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp()
march_11_noon = datetime(2024, 3, 11, 12, 0, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client:
# Insert recordings for each day
@@ -201,19 +201,16 @@ class TestHttpMedia(BaseTestHttp):
In 2024, DST in America/New_York transitions on November 3, 2024 at 2:00 AM
Clocks fall back from 2:00 AM to 1:00 AM (EDT to EST)
"""
tz = pytz.timezone("America/New_York")
tz = ZoneInfo("America/New_York")
# November 2, 2024 at 12:00 PM EDT (before DST transition)
nov_2_noon = tz.localize(datetime(2024, 11, 2, 12, 0, 0)).timestamp()
nov_2_noon = datetime(2024, 11, 2, 12, 0, 0, tzinfo=tz).timestamp()
# November 3, 2024 at 12:00 PM EST (after DST transition)
# Need to specify is_dst=False to get the time after fall back
nov_3_noon = tz.localize(
datetime(2024, 11, 3, 12, 0, 0), is_dst=False
).timestamp()
nov_3_noon = datetime(2024, 11, 3, 12, 0, 0, tzinfo=tz).timestamp()
# November 4, 2024 at 12:00 PM EST (after DST)
nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp()
nov_4_noon = datetime(2024, 11, 4, 12, 0, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client:
# Insert recordings for each day
@@ -274,13 +271,13 @@ class TestHttpMedia(BaseTestHttp):
"""
Test recordings summary with multiple cameras across DST boundary.
"""
tz = pytz.timezone("America/New_York")
tz = ZoneInfo("America/New_York")
# March 9, 2024 at 10:00 AM EST (before DST)
march_9_morning = tz.localize(datetime(2024, 3, 9, 10, 0, 0)).timestamp()
march_9_morning = datetime(2024, 3, 9, 10, 0, 0, tzinfo=tz).timestamp()
# March 10, 2024 at 3:00 PM EDT (after DST transition)
march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp()
march_10_afternoon = datetime(2024, 3, 10, 15, 0, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client:
# Override allowed cameras for this test to include both
@@ -343,15 +340,15 @@ class TestHttpMedia(BaseTestHttp):
"""
Test recordings that span the exact DST transition time.
"""
tz = pytz.timezone("America/New_York")
tz = ZoneInfo("America/New_York")
# March 10, 2024 at 1:00 AM EST (1 hour before DST transition)
# At 2:00 AM, clocks jump to 3:00 AM
before_transition = tz.localize(datetime(2024, 3, 10, 1, 0, 0)).timestamp()
before_transition = datetime(2024, 3, 10, 1, 0, 0, tzinfo=tz).timestamp()
# Recording that spans the transition (1:00 AM to 3:30 AM EDT)
# This is 1.5 hours of actual time but spans the "missing" hour
after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp()
after_transition = datetime(2024, 3, 10, 3, 30, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client:
Recordings.insert(
@@ -411,7 +408,7 @@ class TestHttpMedia(BaseTestHttp):
# Test with UTC timezone
response = client.get(
"/recordings/summary", params={"timezone": "utc", "cameras": "all"}
"/recordings/summary", params={"timezone": "UTC", "cameras": "all"}
)
assert response.status_code == 200
@@ -442,8 +439,8 @@ class TestHttpMedia(BaseTestHttp):
"""
Test recordings summary filtered to a single camera.
"""
tz = pytz.timezone("America/New_York")
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
tz = ZoneInfo("America/New_York")
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client:
# Insert recordings for both cameras
+1 -1
View File
@@ -342,7 +342,7 @@ class TestHttpReview(BaseTestHttp):
"cameras": "front_door",
"labels": "all",
"zones": "all",
"timezone": "utc",
"timezone": "UTC",
}
response = client.get("/review/summary", params=params)
assert response.status_code == 200
+2
View File
@@ -183,10 +183,12 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
}
maintainer.forcibly_end_segment = MagicMock()
maintainer.frame_manager = MagicMock()
maintainer._handle_camera_removed("deleted_cam")
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
maintainer.frame_manager.close_camera.assert_called_once_with("deleted_cam")
self.assertNotIn("deleted_cam", maintainer.indefinite_events)
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
@@ -0,0 +1,33 @@
"""Tests for object classification example extraction."""
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
from frigate.util.classification import _extract_event_thumbnails
class TestExtractEventThumbnails(unittest.TestCase):
def test_falls_back_past_missing_images_and_stops_at_target(self):
events = [SimpleNamespace(id=f"event_{i}") for i in range(10)]
missing = {"event_0", "event_1", "event_2"}
image = np.zeros((32, 32, 3), dtype=np.uint8)
with (
tempfile.TemporaryDirectory() as tmpdir,
patch(
"frigate.util.classification._load_event_classification_crop",
side_effect=lambda event: None if event.id in missing else image,
) as load,
):
paths = _extract_event_thumbnails(events, tmpdir, target_count=4)
self.assertEqual(len(paths), 4)
self.assertEqual(load.call_count, 7)
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -653,6 +653,17 @@ class TestStagedFileCleanup(unittest.TestCase):
self.assertEqual(os.listdir(tmpdir), [])
def test_unwritable_staging_directory_fails_the_run(self) -> None:
"""A raise here would skip the failed-export cleanup."""
with tempfile.TemporaryDirectory() as tmpdir:
exporter = self._exporter(tmpdir)
runs = [StreamRun("main", 1_000, 1_020, "/m1.mp4")]
with patch(
"frigate.record.export.os.makedirs", side_effect=OSError("no space")
):
self.assertFalse(exporter._stage_stream_runs(runs, {"h264"}, False))
class TestStagingFailure(unittest.TestCase):
def test_failed_staging_aborts_rather_than_falling_back(self) -> None:
+41
View File
@@ -0,0 +1,41 @@
import unittest
import cv2
import numpy as np
from frigate.util.image import get_histogram
def reference_histogram(image, x_min, y_min, x_max, y_max):
bgr = cv2.cvtColor(image, cv2.COLOR_YUV2BGR_I420)[y_min:y_max, x_min:x_max]
hist = cv2.calcHist([bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
return cv2.normalize(hist, hist).flatten()
class TestGetHistogram(unittest.TestCase):
def setUp(self):
rng = np.random.default_rng(0)
self.frame = rng.integers(0, 255, (720 * 3 // 2, 1280), np.uint8)
def test_matches_full_frame_conversion_on_even_box(self):
box = (100, 200, 400, 600)
np.testing.assert_array_equal(
get_histogram(self.frame, *box), reference_histogram(self.frame, *box)
)
def test_odd_box_widens_to_even_edges(self):
np.testing.assert_array_equal(
get_histogram(self.frame, 101, 201, 399, 599),
reference_histogram(self.frame, 100, 200, 400, 600),
)
def test_box_is_clamped_to_frame(self):
np.testing.assert_array_equal(
get_histogram(self.frame, -10, -10, 5000, 5000),
reference_histogram(self.frame, 0, 0, 1280, 720),
)
def test_empty_box_returns_zeros(self):
hist = get_histogram(self.frame, 50, 50, 50, 50)
self.assertEqual(hist.shape, (512,))
self.assertEqual(hist.sum(), 0)
+146
View File
@@ -0,0 +1,146 @@
"""Tracker selection when autotracking config changes at runtime."""
import unittest
from unittest.mock import MagicMock
import numpy as np
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig
from frigate.track.norfair_tracker import NorfairTracker
CAMERA = "ptz_cam"
BOX = (400, 200, 500, 500)
def _config(enabled: bool, track: list[str] | None = None) -> FrigateConfig:
autotracking: dict = {"enabled": enabled, "required_zones": ["zone"]}
if track is not None:
autotracking["track"] = track
return FrigateConfig(
**{
"mqtt": {"enabled": False},
"cameras": {
CAMERA: {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"width": 1280, "height": 720},
"zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}},
"onvif": {"host": "10.0.0.1", "autotracking": autotracking},
}
},
}
)
class TestTrackerSelection(unittest.TestCase):
def setUp(self) -> None:
self.frame_time = 1000.0
def make_tracker(self, enabled: bool) -> NorfairTracker:
camera_config = _config(enabled).cameras[CAMERA]
tracker = NorfairTracker(camera_config, PTZMetrics())
tracker.frame_manager = MagicMock()
tracker.frame_manager.get.return_value = np.zeros(
camera_config.frame_shape_yuv, dtype=np.uint8
)
tracker.ptz_motion_estimator = MagicMock()
tracker.ptz_motion_estimator.motion_estimator.return_value = None
return tracker
def apply_onvif_update(self, tracker: NorfairTracker, config: FrigateConfig):
"""Apply an onvif config update the way the camera process does."""
tracker.camera_config.onvif = config.cameras[CAMERA].onvif
tracker.sync_trackers()
def run_frames(self, tracker: NorfairTracker, count: int, label="person"):
for _ in range(count):
self.frame_time += 0.2
tracker.match_and_update(
"frame",
self.frame_time,
[(label, 0.9, BOX, 30000, 0.33, (0, 0, 640, 640))],
)
def test_disabling_autotracking_falls_back_to_static_tracker(self):
tracker = self.make_tracker(enabled=True)
self.run_frames(tracker, 10)
self.assertIs(tracker.get_tracker("person"), tracker.trackers["person"]["ptz"])
self.apply_onvif_update(tracker, _config(enabled=False))
self.run_frames(tracker, 10)
self.assertIs(tracker.get_tracker("person"), tracker.default_tracker["static"])
self.assertEqual(len(tracker.tracked_objects), 1)
def test_enabling_autotracking_uses_ptz_tracker(self):
tracker = self.make_tracker(enabled=False)
self.run_frames(tracker, 10)
self.assertIs(tracker.get_tracker("person"), tracker.default_tracker["static"])
self.apply_onvif_update(tracker, _config(enabled=True))
self.run_frames(tracker, 10)
self.assertIs(tracker.get_tracker("person"), tracker.trackers["person"]["ptz"])
self.assertEqual(len(tracker.tracked_objects), 1)
def test_removing_label_from_autotracking_keeps_tracking(self):
tracker = self.make_tracker(enabled=True)
self.run_frames(tracker, 10)
self.apply_onvif_update(tracker, _config(enabled=True, track=["car"]))
self.run_frames(tracker, 10)
self.assertNotIn("person", tracker.trackers)
self.assertIs(tracker.get_tracker("person"), tracker.default_tracker["ptz"])
self.assertEqual(len(tracker.tracked_objects), 1)
def test_unrelated_onvif_update_keeps_objects(self):
tracker = self.make_tracker(enabled=True)
self.run_frames(tracker, 10)
ids = set(tracker.tracked_objects)
config = _config(enabled=True)
config.cameras[CAMERA].onvif.password = "changed"
self.apply_onvif_update(tracker, config)
self.run_frames(tracker, 5)
self.assertEqual(set(tracker.tracked_objects), ids)
def test_track_list_edit_while_disabled_keeps_objects(self):
tracker = self.make_tracker(enabled=False)
self.run_frames(tracker, 10)
ids = set(tracker.tracked_objects)
self.apply_onvif_update(tracker, _config(enabled=False, track=["car"]))
self.run_frames(tracker, 5)
self.assertEqual(set(tracker.tracked_objects), ids)
def test_runtime_toggle_keeps_objects(self):
# MQTT and the autotracker only flip enabled, never enabled_in_config
tracker = self.make_tracker(enabled=True)
self.run_frames(tracker, 10)
ids = set(tracker.tracked_objects)
tracker.camera_config.onvif.autotracking.enabled = False
tracker.sync_trackers()
self.run_frames(tracker, 5)
self.assertEqual(set(tracker.tracked_objects), ids)
def test_unlisted_label_uses_the_default_tracker_that_holds_it(self):
tracker = self.make_tracker(enabled=True)
self.run_frames(tracker, 10, label="dog")
default = tracker.get_tracker("dog")
self.assertIs(default, tracker.default_tracker["ptz"])
self.assertEqual(
{str(o.global_id) for o in default.tracked_objects},
set(tracker.track_id_map),
)
+12 -6
View File
@@ -3,7 +3,7 @@ from unittest.mock import MagicMock, Mock, patch
import numpy as np
import zmq
from pydantic import parse_obj_as
from pydantic import TypeAdapter
import frigate.detectors as detectors
import frigate.object_detection.base
@@ -20,8 +20,8 @@ class TestLocalObjectDetector(unittest.TestCase):
"frigate.detectors.api_types",
{det_type: Mock() for det_type in DetectorTypeEnum},
):
test_cfg = parse_obj_as(
DetectorConfig, ({"type": det_type, "model": {}})
test_cfg = TypeAdapter(DetectorConfig).validate_python(
{"type": det_type, "model": {}}
)
test_cfg.model.path = "/test/modelpath"
test_obj = frigate.object_detection.base.LocalObjectDetector(
@@ -45,7 +45,9 @@ class TestLocalObjectDetector(unittest.TestCase):
TEST_DATA = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32])
test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
detector_config=parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
detector_config=TypeAdapter(DetectorConfig).validate_python(
{"type": "cpu", "model": {}}
)
)
mock_det_api = mock_cputfl.return_value
@@ -68,7 +70,9 @@ class TestLocalObjectDetector(unittest.TestCase):
TEST_DATA = np.zeros((1, 32, 32, 3), np.uint8)
TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32])
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
test_cfg = TypeAdapter(DetectorConfig).validate_python(
{"type": "cpu", "model": {}}
)
test_cfg.model.input_tensor = InputTensorEnum.nchw
test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
@@ -117,7 +121,9 @@ class TestLocalObjectDetector(unittest.TestCase):
4: "label-5",
}
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
test_cfg = TypeAdapter(DetectorConfig).validate_python(
{"type": "cpu", "model": {}}
)
test_cfg.model = ModelConfig()
test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
detector_config=test_cfg,
+17
View File
@@ -13,6 +13,7 @@ from frigate.models import Recordings
from frigate.util.recording_coverage import (
_rows_query,
coverage_spans,
null_audio_glitches,
plan_clip,
realized_timeline,
resolve_coverage,
@@ -166,6 +167,22 @@ class TestRecordingCoverage(CoverageDbTestCase):
expected = int(5 * 1024 * 1024 * 8 / 10)
assert summary["main"]["bitrate"] == expected
def test_unknown_audio_row_keeps_video_only_stream(self):
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=False)
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=None)
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=False)
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
self.assertEqual(
[i.sub.path for i in kept], [f"/tmp/s{n}.mp4" for n in (1, 2, 3)]
)
def test_video_only_glitch_dropped_on_audio_stream(self):
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=True)
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=False)
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=None)
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
self.assertEqual([i.sub.path for i in kept], ["/tmp/s1.mp4", "/tmp/s3.mp4"])
def test_other_camera_rows_excluded(self):
self._insert("m1", 1000.0, 1010.0, "main")
self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard")
+2 -2
View File
@@ -74,8 +74,8 @@ class ReviewFlowTestCase(unittest.TestCase):
maintainer.config_subscriber.check_for_updates.return_value = {}
maintainer.detection_subscriber = MagicMock()
maintainer.frame_manager = MagicMock()
maintainer.frame_manager.get.side_effect = lambda _name, shape: np.zeros(
shape, np.uint8
maintainer.frame_manager.get.side_effect = lambda _name, shape, _t=None: (
np.zeros(shape, np.uint8)
)
return maintainer
@@ -6,13 +6,20 @@ in-process cache (e.g. TrackedObjectProcessor) still holds a ref to
the old, smaller segment.
"""
import queue
import threading
import unittest
from multiprocessing import Event, Value
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import numpy as np
from frigate.embeddings.maintainer import EmbeddingMaintainer
from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.image import SharedMemoryFrameManager
from frigate.video.ffmpeg import capture_frames
def _fake_shm(size: int) -> SimpleNamespace:
@@ -152,5 +159,319 @@ class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase):
self.assertEqual(arr_large.shape, (1620, 1920))
def _segments(**sizes: int) -> dict[str, SimpleNamespace]:
return {name: _fake_shm(size) for name, size in sizes.items()}
def _open_from(segments: dict[str, SimpleNamespace]):
def open_segment(name: str, **_kwargs):
if name not in segments:
raise FileNotFoundError(name)
return segments[name]
return open_segment
class TestSharedMemoryFrameManagerFrameTimes(unittest.TestCase):
def test_frame_times_is_float64_view_over_ledger_segment(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame_times=8 * 3)
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
times = manager.frame_times("cam")
self.assertEqual(times.dtype, np.float64)
self.assertEqual(times.shape, (3,))
times[1] = 12.5
self.assertEqual(
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[1], 12.5
)
def test_frame_times_returns_none_when_ledger_missing(self) -> None:
manager = SharedMemoryFrameManager()
with patch(
"frigate.util.image.UntrackedSharedMemory", side_effect=FileNotFoundError
):
self.assertIsNone(manager.frame_times("cam"))
def test_get_returns_frame_when_ledger_matches(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame0=2_500, cam_frame_times=8 * 2)
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[0] = 100.25
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
arr = manager.get("cam_frame0", (50, 50), frame_time=100.25)
self.assertIsNotNone(arr)
self.assertEqual(arr.shape, (50, 50))
def test_get_returns_none_when_slot_was_overwritten(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame0=2_500, cam_frame_times=8 * 2)
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[0] = 200.0
with (
patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
),
self.assertLogs("frigate.util.image", "DEBUG") as logs,
):
arr = manager.get("cam_frame0", (50, 50), frame_time=100.25)
self.assertIsNone(arr)
self.assertIn(
"cam_frame0 no longer holds the frame from 100.25", logs.output[0]
)
def test_get_returns_none_when_slot_index_is_beyond_ledger(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame5=2_500, cam_frame_times=8 * 2)
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
arr = manager.get("cam_frame5", (50, 50), frame_time=100.25)
self.assertIsNone(arr)
def test_get_reopens_every_cached_slot_when_ledger_is_stale(self) -> None:
"""A camera recreated at the same resolution gets new segments under
the old names. A long-lived reader still mapped to the unlinked ledger
sees a mismatch, which must drop every cached slot of that camera, not
only the one being read, or later reads return the old pixels."""
manager = SharedMemoryFrameManager()
stale = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2)
manager.shm_store.update(stale)
current = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2)
current["cam_frame1"].buf[0] = 7
np.frombuffer(current["cam_frame_times"].buf, dtype=np.float64)[:] = (
100.25,
100.5,
)
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(current),
):
first = manager.get("cam_frame0", (50, 50), frame_time=100.25)
second = manager.get("cam_frame1", (50, 50), frame_time=100.5)
self.assertIsNotNone(first)
self.assertEqual(second[0, 0], 7)
self.assertIs(manager.shm_store["cam_frame1"], current["cam_frame1"])
def test_get_keeps_cached_slots_when_only_lapped(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2)
manager.shm_store.update(segments)
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[0] = 200.0
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
arr = manager.get("cam_frame0", (50, 50), frame_time=100.25)
self.assertIsNone(arr)
self.assertIs(manager.shm_store["cam_frame1"], segments["cam_frame1"])
def test_close_camera_drops_only_that_camera(self) -> None:
manager = SharedMemoryFrameManager()
manager.shm_store.update(
_segments(cam_frame0=1, cam_frame1=1, cam_frame_times=8, other_frame0=1)
)
# a camera named cam_frame shares the cam_frame prefix
manager.shm_store.update(
{"cam_frame_frame0": _fake_shm(1), "cam_frame_frame_times": _fake_shm(8)}
)
manager.close_camera("cam")
self.assertEqual(
set(manager.shm_store),
{"other_frame0", "cam_frame_frame0", "cam_frame_frame_times"},
)
def test_get_without_frame_time_skips_ledger(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame0=2_500)
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNotNone(arr)
self.assertNotIn("cam_frame_times", manager.shm_store)
class TestCaptureFrames(unittest.TestCase):
"""capture_frames stamps the ledger and only advances the slot index
once a frame has been handed to the queue."""
def _run(self, frames: list[bytes], put_results: list[Exception | None]):
frame_size = 4
slots = {f"cam_frame{i}": bytearray(frame_size) for i in range(3)}
times = np.zeros(3, dtype=np.float64)
frame_manager = MagicMock()
frame_manager.write.side_effect = lambda name: memoryview(slots[name])
frame_manager.frame_times.return_value = times
stop_event = Event()
reads = iter(frames)
self.times_during_read: list[np.ndarray] = []
def readinto(buffer):
self.times_during_read.append(times.copy())
try:
frame = next(reads)
except StopIteration:
stop_event.set()
return 0
buffer[:] = frame
return len(frame)
ffmpeg_process = MagicMock()
ffmpeg_process.stdout.readinto.side_effect = readinto
ffmpeg_process.poll.return_value = None
frame_queue = MagicMock()
frame_queue.put.side_effect = put_results
config = SimpleNamespace(name="cam", enabled=True)
capture_frames(
ffmpeg_process,
config,
3,
0,
(2, 2),
frame_manager,
frame_queue,
Value("d", 0.0),
Value("d", 0.0),
Value("d", 0.0),
stop_event,
)
return slots, times, [c.args[0] for c in frame_queue.put.call_args_list]
def test_advances_slot_and_stamps_ledger_on_success(self) -> None:
slots, times, puts = self._run([b"aaaa", b"bbbb"], [None, None])
self.assertEqual([name for name, _ in puts], ["cam_frame0", "cam_frame1"])
self.assertEqual(bytes(slots["cam_frame0"]), b"aaaa")
self.assertEqual(bytes(slots["cam_frame1"]), b"bbbb")
self.assertEqual(times[0], puts[0][1])
self.assertEqual(times[1], puts[1][1])
self.assertGreater(times[0], 0)
def test_reuses_slot_when_queue_is_full(self) -> None:
slots, times, puts = self._run(
[b"aaaa", b"bbbb", b"cccc"], [None, queue.Full(), None]
)
self.assertEqual(
[name for name, _ in puts], ["cam_frame0", "cam_frame1", "cam_frame1"]
)
self.assertEqual(bytes(slots["cam_frame1"]), b"cccc")
self.assertEqual(bytes(slots["cam_frame2"]), bytes(4))
self.assertEqual(times[1], puts[2][1])
self.assertEqual(times[2], 0)
def test_slot_never_matches_while_it_is_being_filled(self) -> None:
_, _, puts = self._run([b"aaaa", b"bbbb"], [queue.Full(), None])
# the second read refills slot 0, which the first frame had stamped
self.assertEqual([name for name, _ in puts], ["cam_frame0", "cam_frame0"])
self.assertEqual(self.times_during_read[1][0], 0)
class TestTrackedObjectEventsCarryFrameTime(unittest.TestCase):
"""Tracked object events carry the time of the frame they name, which can
differ from the object's own frame_time when it wasn't detected in that
frame."""
def _callbacks(self) -> tuple[dict, MagicMock]:
processor = TrackedObjectProcessor.__new__(TrackedObjectProcessor)
processor.config = SimpleNamespace(cameras={"cam": SimpleNamespace(name="cam")})
processor.event_sender = MagicMock()
processor.dispatcher = MagicMock()
processor.ptz_autotracker_thread = MagicMock()
processor.camera_states = {}
processor.camera_states_lock = threading.Lock()
processor.frame_manager = MagicMock()
processor.should_save_snapshot = MagicMock(return_value=False)
processor.should_retain_recording = MagicMock(return_value=False)
callbacks: dict = {}
with patch("frigate.track.object_processing.CameraState") as state_cls:
state_cls.return_value.on.side_effect = lambda name, fn: callbacks.update(
{name: fn}
)
processor.create_camera_state("cam")
return callbacks, processor.event_sender
def test_start_update_end_publish_frame_time(self) -> None:
callbacks, sender = self._callbacks()
obj = MagicMock()
obj.to_dict.return_value = {"id": "1", "frame_time": 10.0}
obj.obj_data = {"frame_time": 10.0}
obj.previous = {"false_positive": True}
obj.false_positive = True
callbacks["start"]("cam", obj, "cam_frame3", 12.5)
callbacks["update"]("cam", obj, "cam_frame4", 13.0)
callbacks["end"]("cam", obj, "cam_frame5", 13.5)
payloads = [c.args[0] for c in sender.publish.call_args_list]
self.assertEqual(
[(p[1], p[3], p[5]) for p in payloads],
[
(EventStateEnum.start, "cam_frame3", 12.5),
(EventStateEnum.update, "cam_frame4", 13.0),
(EventStateEnum.end, "cam_frame5", 13.5),
],
)
class TestEmbeddingsObjectUpdateVerifiesFrame(unittest.TestCase):
def test_object_update_reads_frame_at_event_frame_time(self) -> None:
maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer)
maintainer.config = SimpleNamespace(
cameras={"cam": SimpleNamespace(frame_shape_yuv=(6, 4))},
semantic_search=SimpleNamespace(enabled=False),
)
maintainer.event_subscriber = MagicMock()
maintainer.event_subscriber.check_for_update.return_value = (
EventTypeEnum.tracked_object,
EventStateEnum.update,
"cam",
"cam_frame4",
{"id": "1", "label": "person", "frame_time": 10.0},
13.0,
)
maintainer.realtime_processors = [MagicMock()]
maintainer.post_processors = []
maintainer.frame_manager = MagicMock()
maintainer._process_updates()
maintainer.frame_manager.get.assert_called_once_with("cam_frame4", (6, 4), 13.0)
maintainer.frame_manager.close.assert_not_called()
if __name__ == "__main__":
unittest.main()
+20
View File
@@ -0,0 +1,20 @@
import unittest
from frigate.util.time import get_normalized_tz_name
class TestGetNormalizedTzName(unittest.TestCase):
def test_valid(self):
cases = [
("utc", "UTC"),
("america/new_york", "America/New_York"),
("us/eastern", "US/Eastern"),
]
for tz_name, expected in cases:
with self.subTest(tz_name=tz_name):
self.assertEqual(get_normalized_tz_name(tz_name), expected)
def test_invalid(self):
with self.assertRaisesRegex(ValueError, r"Unknown timezone: foo/bar"):
get_normalized_tz_name("foo/bar")
+86 -57
View File
@@ -180,48 +180,9 @@ class NorfairTracker(ObjectTracker):
}
self.trackers: dict[str, dict[str, Tracker]] = {}
# Handle static trackers
for obj_type, tracker_config in self.object_type_configs.items():
if obj_type in self.camera_config.objects.track:
if obj_type not in self.trackers:
self.trackers[obj_type] = {}
self.trackers[obj_type]["static"] = self._create_tracker(
obj_type, tracker_config
)
# Handle PTZ trackers
for obj_type, tracker_config in self.ptz_object_type_configs.items():
if (
obj_type in self.camera_config.onvif.autotracking.track
and self.camera_config.onvif.autotracking.enabled_in_config
):
if obj_type not in self.trackers:
self.trackers[obj_type] = {}
self.trackers[obj_type]["ptz"] = self._create_tracker(
obj_type, tracker_config
)
# Initialize default trackers
self.default_tracker = {
"static": Tracker(
distance_function=frigate_distance,
distance_threshold=self.default_tracker_config[ # type: ignore[arg-type]
"distance_threshold"
],
initialization_delay=self.detect_config.min_initialized,
hit_counter_max=self.detect_config.max_disappeared, # type: ignore[arg-type]
filter_factory=self.default_tracker_config["filter_factory"], # type: ignore[arg-type]
),
"ptz": Tracker(
distance_function=frigate_distance,
distance_threshold=self.default_ptz_tracker_config[
"distance_threshold"
], # type: ignore[arg-type]
initialization_delay=self.detect_config.min_initialized,
hit_counter_max=self.detect_config.max_disappeared, # type: ignore[arg-type]
filter_factory=self.default_ptz_tracker_config["filter_factory"], # type: ignore[arg-type]
),
}
self.default_tracker: dict[str, Tracker] = {}
self.tracker_selection: tuple[bool | None, tuple[str, ...]] | None = None
self.sync_trackers()
if self.camera_config.onvif.autotracking.enabled:
self.ptz_motion_estimator = PtzMotionEstimator(
@@ -257,18 +218,91 @@ class NorfairTracker(ObjectTracker):
return Tracker(**tracker_params)
def get_tracker(self, object_type: str) -> Tracker:
"""Get the appropriate tracker based on object type and camera mode."""
mode = (
def _tracker_selection(self) -> tuple[bool | None, tuple[str, ...]]:
autotracking = self.camera_config.onvif.autotracking
if not autotracking.enabled_in_config:
return (False, ())
ptz_labels = tuple(
label
for label in self.ptz_object_type_configs
if label in autotracking.track
)
return (True, ptz_labels)
def sync_trackers(self) -> None:
"""Rebuild the trackers when the config that selects them has changed.
The camera process receives onvif config updates at runtime, so which
labels use a PTZ tracker can change after startup. Rebuilding drops
norfair state, so an update that leaves the selection alone is a no-op.
"""
selection = self._tracker_selection()
if selection == self.tracker_selection:
return
if self.tracker_selection is not None:
logger.debug(
"%s: autotracking changed, rebuilding trackers", self.camera_name
)
self.tracker_selection = selection
ptz_enabled, ptz_labels = selection
self.trackers = {}
for obj_type, tracker_config in self.object_type_configs.items():
if obj_type in self.camera_config.objects.track:
self.trackers.setdefault(obj_type, {})["static"] = self._create_tracker(
obj_type, tracker_config
)
if ptz_enabled:
for obj_type, tracker_config in self.ptz_object_type_configs.items():
if obj_type in ptz_labels:
self.trackers.setdefault(obj_type, {})["ptz"] = (
self._create_tracker(obj_type, tracker_config)
)
self.default_tracker = {
"static": Tracker(
distance_function=frigate_distance,
distance_threshold=self.default_tracker_config[ # type: ignore[arg-type]
"distance_threshold"
],
initialization_delay=self.detect_config.min_initialized,
hit_counter_max=self.detect_config.max_disappeared, # type: ignore[arg-type]
filter_factory=self.default_tracker_config["filter_factory"], # type: ignore[arg-type]
),
"ptz": Tracker(
distance_function=frigate_distance,
distance_threshold=self.default_ptz_tracker_config[
"distance_threshold"
], # type: ignore[arg-type]
initialization_delay=self.detect_config.min_initialized,
hit_counter_max=self.detect_config.max_disappeared, # type: ignore[arg-type]
filter_factory=self.default_ptz_tracker_config["filter_factory"], # type: ignore[arg-type]
),
}
def _default_mode(self) -> str:
"""Pick the default tracker from saved config, not the runtime toggle."""
return (
"ptz"
if self.camera_config.onvif.autotracking.enabled_in_config
and object_type in self.camera_config.onvif.autotracking.track
and object_type in self.ptz_object_type_configs.keys()
else "static"
)
if object_type in self.trackers:
return self.trackers[object_type][mode]
return self.default_tracker[mode]
def get_tracker(self, object_type: str) -> Tracker:
"""Get the tracker that match_and_update feeds this label's detections to."""
trackers = self.trackers.get(object_type)
if trackers is None:
return self.default_tracker[self._default_mode()]
# sync_trackers only creates a ptz tracker for labels the config selects
return trackers["ptz"] if "ptz" in trackers else trackers["static"]
def register(self, track_id: str, obj: dict[str, Any]) -> None:
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
@@ -534,7 +568,7 @@ class NorfairTracker(ObjectTracker):
points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]])
embedding = None
if self.camera_config.onvif.autotracking.enabled:
if self.camera_config.onvif.autotracking.enabled and yuv_frame is not None:
embedding = get_histogram(
yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3]
)
@@ -587,12 +621,7 @@ class NorfairTracker(ObjectTracker):
default_detections.extend(dets)
# Update default tracker with untracked detections
mode = (
"ptz"
if self.camera_config.onvif.autotracking.enabled_in_config
else "static"
)
tracked_objects = self.default_tracker[mode].update(
tracked_objects = self.default_tracker[self._default_mode()].update(
detections=default_detections, coord_transformations=coord_transformations
)
all_tracked_objects.extend(tracked_objects)
+21 -5
View File
@@ -118,7 +118,9 @@ class TrackedObjectProcessor(threading.Thread):
def create_camera_state(self, camera: str) -> None:
"""Creates a new camera state."""
def start(camera: str, obj: TrackedObject, frame_name: str) -> None:
def start(
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
self.event_sender.publish(
(
EventTypeEnum.tracked_object,
@@ -126,10 +128,13 @@ class TrackedObjectProcessor(threading.Thread):
camera,
frame_name,
obj.to_dict(),
frame_time,
)
)
def update(camera: str, obj: TrackedObject, frame_name: str) -> None:
def update(
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj
)
@@ -149,13 +154,18 @@ class TrackedObjectProcessor(threading.Thread):
camera,
frame_name,
obj.to_dict(),
frame_time,
)
)
def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None:
def autotrack(
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
self.ptz_autotracker_thread.autotrack_object(camera, obj)
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
def end(
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
# populate has_snapshot
obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj
@@ -186,6 +196,7 @@ class TrackedObjectProcessor(threading.Thread):
camera,
frame_name,
obj.to_dict(),
frame_time,
)
)
@@ -599,6 +610,7 @@ class TrackedObjectProcessor(threading.Thread):
"type": source_type,
"draw": draw,
},
None,
)
)
@@ -664,6 +676,7 @@ class TrackedObjectProcessor(threading.Thread):
"recognized_license_plate": plate,
"recognized_license_plate_score": score,
},
None,
)
)
@@ -692,6 +705,7 @@ class TrackedObjectProcessor(threading.Thread):
None,
"",
{"id": event_id, "end_time": end_time},
None,
)
)
@@ -713,13 +727,14 @@ class TrackedObjectProcessor(threading.Thread):
def force_end_all_events(self, camera: str, camera_state: CameraState) -> None:
"""Ends all active events on camera when disabling."""
last_frame_name = camera_state.previous_frame_id
last_frame_time = camera_state.current_frame_time
for obj_id, obj in list(camera_state.tracked_objects.items()):
if "end_time" not in obj.obj_data:
logger.debug(f"Camera {camera} disabled, ending active event {obj_id}")
obj.obj_data["end_time"] = datetime.datetime.now().timestamp()
# end callbacks
for callback in camera_state.callbacks["end"]:
callback(camera, obj, last_frame_name)
callback(camera, obj, last_frame_name, last_frame_time)
# camera activity callbacks
for callback in camera_state.callbacks["camera_activity"]:
@@ -748,6 +763,7 @@ class TrackedObjectProcessor(threading.Thread):
continue
camera_state.shutdown()
self.frame_manager.close_camera(camera)
with self.camera_states_lock:
self.camera_states.pop(camera)
+20 -4
View File
@@ -747,8 +747,14 @@ def collect_object_classification_examples(
selected_events = _select_balanced_events(events, target_count=100)
logger.debug(f"Selected {len(selected_events)} events")
# Step 3: Extract thumbnails from events
thumbnails = _extract_event_thumbnails(selected_events, temp_dir)
# Step 3: Extract thumbnails from events, falling back to the remaining
# events when the selected ones have no image on disk
selected_ids = {e.id for e in selected_events}
remaining_events = [e for e in events if e.id not in selected_ids]
random.shuffle(remaining_events)
thumbnails = _extract_event_thumbnails(
selected_events + remaining_events, temp_dir, target_count=100
)
logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails")
# Step 4: Select 24 most visually distinct thumbnails
@@ -833,10 +839,16 @@ def _select_balanced_events(
else:
selected.extend(remaining)
# groups are ordered oldest first, so truncating unshuffled keeps only the
# oldest events, which are the least likely to still have images on disk
random.shuffle(selected)
return selected[:target_count]
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]:
def _extract_event_thumbnails(
events: list[Event], output_dir: str, target_count: int = 100
) -> list[str]:
"""
Extract a training image for each event.
@@ -850,8 +862,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
using a step ladder sized from the box/region area ratio.
Args:
events: List of Event objects
events: List of Event objects, in order of preference
output_dir: Directory to save crops
target_count: Number of images to extract before stopping
Returns:
List of paths to successfully extracted images
@@ -859,6 +872,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
image_paths = []
for idx, event in enumerate(events):
if len(image_paths) >= target_count:
break
try:
img = _load_event_classification_crop(event)
if img is None:
+105 -5
View File
@@ -990,13 +990,23 @@ class FrameManager(ABC):
pass
@abstractmethod
def get(self, name: str, timeout_ms: int = 0):
def get(
self, name: str, shape: tuple[int, ...], frame_time: float | None = None
) -> np.ndarray | None:
pass
@abstractmethod
def frame_times(self, camera: str) -> np.ndarray | None:
pass
@abstractmethod
def close(self, name: str):
pass
@abstractmethod
def close_camera(self, camera: str) -> None:
pass
@abstractmethod
def delete(self, name: str):
pass
@@ -1079,7 +1089,56 @@ class SharedMemoryFrameManager(FrameManager):
logger.info(f"the file {name} not found")
return None
def get(self, name: str, shape) -> np.ndarray | None:
def frame_times(self, camera: str) -> np.ndarray | None:
"""Per-slot write times for a camera's frame ring, stamped by capture."""
buf = self.write(f"{camera}_frame_times")
if buf is None:
return None
return np.ndarray((len(buf) // 8,), dtype=np.float64, buffer=buf)
def _holds(self, name: str, frame_time: float) -> bool:
camera, _, index = name.rpartition("_frame")
times = self.frame_times(camera)
return (
times is not None
and index.isdigit()
and int(index) < len(times)
and times[int(index)] == frame_time
)
def get(
self, name: str, shape: tuple[int, ...], frame_time: float | None = None
) -> np.ndarray | None:
"""Map a frame slot as an array without copying it.
The array points into shared memory, so it is only valid until the next
close(), delete(), or get() of the same name, and capture overwrites its
pixels once the ring wraps back to this slot. Copy it to keep it longer.
Args:
name: Slot name, such as front_door_frame3
shape: Array shape, which must match the slot's size
frame_time: Time of the frame the caller was told is in the slot
Returns:
The frame, or None when the slot is missing, the wrong size, or holds
a different frame than frame_time
"""
if frame_time is not None and not self._holds(name, frame_time):
# a ledger left over from a same-size recreate of the camera looks
# the same as a lapped slot, so reopen it by name once
camera = name.rpartition("_frame")[0]
self.close(f"{camera}_frame_times")
if not self._holds(name, frame_time):
logger.debug("%s no longer holds the frame from %s", name, frame_time)
return None
# the ledger was replaced, so every cached slot maps the old segments
self.close_camera(camera)
try:
required = int(np.prod(shape))
shm = self.shm_store.get(name)
@@ -1110,6 +1169,14 @@ class SharedMemoryFrameManager(FrameManager):
self.shm_store[name].close()
del self.shm_store[name]
def close_camera(self, camera: str) -> None:
"""Drop every cached mapping for a camera's frame ring and ledger."""
for name in list(self.shm_store):
owner, _, suffix = name.rpartition("_frame")
if owner == camera and (suffix.isdigit() or suffix == "_times"):
self.close(name)
def delete(self, name: str):
if name in self.shm_store:
self.shm_store[name].close()
@@ -1241,9 +1308,42 @@ def get_image_from_recording(
return image_data
def get_histogram(image, x_min, y_min, x_max, y_max):
image_bgr = cv2.cvtColor(image, cv2.COLOR_YUV2BGR_I420)
image_bgr = image_bgr[y_min:y_max, x_min:x_max]
def get_histogram(
image: np.ndarray, x_min: int, y_min: int, x_max: int, y_max: int
) -> np.ndarray:
"""Return a normalized 8x8x8 BGR histogram of a box in an I420 frame.
The box is cropped from each YUV plane before color conversion, so the
cost depends on the box size rather than the frame size. Box edges are
widened to even coordinates to keep chroma alignment.
"""
height = image.shape[0] * 2 // 3
width = image.shape[1]
x_min = max(0, x_min // 2 * 2)
y_min = max(0, y_min // 2 * 2)
x_max = min(width, (x_max + 1) // 2 * 2)
y_max = min(height, (y_max + 1) // 2 * 2)
if x_max - x_min < 2 or y_max - y_min < 2:
return np.zeros(512, np.float32)
flat = image.reshape(-1)
y_size = height * width
uv_size = y_size // 4
y_plane = flat[:y_size].reshape(height, width)
u_plane = flat[y_size : y_size + uv_size].reshape(height // 2, width // 2)
v_plane = flat[y_size + uv_size : y_size + 2 * uv_size].reshape(
height // 2, width // 2
)
crop = np.concatenate(
(
y_plane[y_min:y_max, x_min:x_max].ravel(),
u_plane[y_min // 2 : y_max // 2, x_min // 2 : x_max // 2].ravel(),
v_plane[y_min // 2 : y_max // 2, x_min // 2 : x_max // 2].ravel(),
)
).reshape((y_max - y_min) * 3 // 2, x_max - x_min)
image_bgr = cv2.cvtColor(crop, cv2.COLOR_YUV2BGR_I420)
hist = cv2.calcHist(
[image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256]
+4 -3
View File
@@ -204,11 +204,12 @@ def coverage_spans(intervals: list[CoverageInterval]) -> list[dict[str, Any]]:
def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
"""Whether a stream is audio-bearing over a coverage window.
A stream counts as audio-bearing unless EVERY one of its rows reports
has_audio False; NULL (legacy or undetermined) counts as audio.
A stream counts as audio-bearing only when one of its rows is known
to carry audio. A NULL row (legacy, or a segment ffprobe could not
read) proves nothing either way.
"""
return any(
row is not None and row.has_audio is not False
row is not None and row.has_audio is True
for row in ((interval.main if main else interval.sub) for interval in intervals)
)
+21 -6
View File
@@ -3,17 +3,32 @@
import datetime
import logging
import math
from zoneinfo import ZoneInfoNotFoundError
from typing import Final
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
import pytz
from tzlocal import get_localzone
logger = logging.getLogger(__name__)
TIMEZONE_CASEFOLD_INDEX: Final = {
timezone.casefold(): timezone for timezone in available_timezones()
}
def get_normalized_tz_name(tz_name: str) -> str:
"""Return the canonical name for a case-insensitive IANA timezone."""
tz = TIMEZONE_CASEFOLD_INDEX.get(tz_name.casefold())
if tz:
return tz
raise ValueError(f"Unknown timezone: {tz_name}")
def get_tz_modifiers(tz_name: str) -> tuple[str, str, float]:
seconds_offset = (
datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()
datetime.datetime.now(ZoneInfo(get_normalized_tz_name(tz_name)))
.utcoffset()
.total_seconds()
)
hours_offset = int(seconds_offset / 60 / 60)
minutes_offset = int(seconds_offset / 60 - hours_offset * 60)
@@ -26,7 +41,7 @@ def get_tomorrow_at_time(hour: int) -> datetime.datetime:
"""Returns the datetime of the following day at 2am."""
try:
tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1)
except ZoneInfoNotFoundError:
except (ValueError, ZoneInfoNotFoundError):
tomorrow = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
logger.warning(
"Using utc for maintenance due to missing or incorrect timezone set"
@@ -84,8 +99,8 @@ def get_dst_transitions(
continuous periods with the same UTC offset
"""
try:
tz = pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
tz = ZoneInfo(get_normalized_tz_name(tz_name))
except (ValueError, ZoneInfoNotFoundError):
# If timezone is invalid, return single period with no offset
return [(start_time, end_time, 0)]
+6 -2
View File
@@ -144,6 +144,11 @@ class CameraTracker(FrigateProcess):
def run(self) -> None:
self.pre_run_setup(self.log_config)
# OpenCV only sees small images here, and its default per-core thread
# pool burns ~5x the CPU of running them inline
cv2.setNumThreads(1)
frame_queue = self.camera_metrics.frame_queue
frame_shape = self.config.frame_shape
@@ -302,6 +307,7 @@ def process_frames(
motion_detector.autotracking_enabled = (
camera_config.onvif.autotracking.enabled
)
object_tracker.sync_trackers()
if (
not camera_enabled
@@ -601,7 +607,6 @@ def process_frames(
)
# add to the queue if not full
if detected_objects_queue.full():
frame_manager.close(frame_name)
dropped_frames.dropped(time.monotonic())
continue
else:
@@ -618,7 +623,6 @@ def process_frames(
)
)
camera_metrics.detection_fps.value = object_detector.fps.eps()
frame_manager.close(frame_name)
motion_detector.stop()
requestor.stop()
+11 -3
View File
@@ -64,6 +64,7 @@ def capture_frames(
frame_rate.start()
skipped_eps = EventsPerSecond()
skipped_eps.start()
frame_times = frame_manager.frame_times(config.name)
while not stop_event.is_set():
# CameraWatchdog applies enabled updates onto this same CameraConfig
@@ -78,8 +79,14 @@ def capture_frames(
current_frame.value = datetime.now().timestamp()
frame_name = f"{config.name}_frame{frame_index}"
frame_buffer = frame_manager.write(frame_name)
# readinto fills the slot for the whole pipe wait, so readers must not
# match it until the frame is complete
frame_times[frame_index] = 0
try:
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
if ffmpeg_process.stdout.readinto(frame_buffer) != frame_size:
raise EOFError("incomplete frame")
except Exception:
# shutdown has been initiated
if stop_event.is_set():
@@ -95,16 +102,17 @@ def capture_frames(
continue
frame_times[frame_index] = current_frame.value
frame_rate.update()
# don't lock the queue to check, just try since it should rarely be full
try:
# add to the queue
frame_queue.put((frame_name, current_frame.value), False)
frame_manager.close(frame_name)
except queue.Full:
# if the queue is full, skip this frame
# nobody was told about this slot, so the next frame reuses it
skipped_eps.update()
continue
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
+70 -2
View File
@@ -6,9 +6,9 @@
* viewer nav restrictions; all-routes smoke.
*/
import { test, expect } from "../fixtures/frigate-test";
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
import { systemTab } from "../helpers/system-tabs";
import { viewerProfile } from "../fixtures/mock-data/profile";
import { adminProfile, viewerProfile } from "../fixtures/mock-data/profile";
test.describe("Auth — admin access @high", () => {
test("admin /system renders general tab", async ({ frigateApp }) => {
@@ -109,3 +109,71 @@ test.describe("Auth — all routes smoke @high @mobile", () => {
}
});
});
test.describe("Auth: login redirect @high", () => {
// 401 on /profile is how the app learns that a login is required
test.use({ expectedErrors: [/401/] });
async function requireLogin(frigateApp: FrigateApp) {
let loggedIn = false;
await frigateApp.page.route("**/api/profile", (route) =>
loggedIn
? route.fulfill({ json: adminProfile() })
: route.fulfill({ status: 401, body: "" }),
);
await frigateApp.page.route("**/api/login", (route) => {
loggedIn = true;
return route.fulfill({ json: {} });
});
await frigateApp.page.route("**/api/auth/first_time_login", (route) =>
route.fulfill({ json: { admin_first_time_login: false } }),
);
}
async function logIn(frigateApp: FrigateApp) {
await frigateApp.page.getByLabel("Username").fill("admin");
await frigateApp.page.getByLabel("Password").fill("password");
await frigateApp.page.getByRole("button", { name: "Login" }).click();
}
test("returns to the requested url after logging in", async ({
frigateApp,
}) => {
await requireLogin(frigateApp);
await frigateApp.page.goto("/explore?labels=person");
await expect(frigateApp.page).toHaveURL(
`/login?redirect=${encodeURIComponent("/explore?labels=person")}`,
);
await logIn(frigateApp);
await expect(frigateApp.page).toHaveURL("/explore?labels=person");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
timeout: 10_000,
});
});
test("goes home after logging in from the home page", async ({
frigateApp,
}) => {
await requireLogin(frigateApp);
await frigateApp.page.goto("/");
await expect(frigateApp.page).toHaveURL("/login");
await logIn(frigateApp);
await expect(frigateApp.page).toHaveURL("/");
});
for (const target of ["https://example.com/", "//example.com/"]) {
test(`ignores a redirect to another site (${target})`, async ({
frigateApp,
}) => {
await requireLogin(frigateApp);
await frigateApp.page.goto(
`/login?redirect=${encodeURIComponent(target)}`,
);
await logIn(frigateApp);
await expect(frigateApp.page).toHaveURL("/");
});
}
});
@@ -66,6 +66,27 @@ const HAILO_PLUS_MODELS = [
},
];
// a migrated `type: onnx` detector is the bare "onnx", and a Ryzen iGPU is
// always probed alongside an NVIDIA card
const ONNX_GPU_HARDWARE = [
{
key: "onnx:nvidia",
detector: "onnx",
name: "NVIDIA GeForce RTX 3060",
units: [{ device: "onnx:0", label: "NVIDIA GeForce RTX 3060" }],
count: 1,
unlimited: true,
},
{
key: "onnx:amd",
detector: "onnx",
name: "AMD GPU",
units: [{ device: "onnx", label: "renderD128" }],
count: 1,
unlimited: true,
},
];
const HAILO_HARDWARE = [
{
key: "hailo",
@@ -84,7 +105,7 @@ async function installRoutes(
models: Model[],
plusEnabled = false,
plusModels: unknown[] = [PLUS_MODEL],
hailoHardware = false,
hardware?: unknown[],
) {
const config = configFactory({
models,
@@ -107,9 +128,9 @@ async function installRoutes(
route.fulfill({ json: plusModels }),
);
if (hailoHardware) {
if (hardware) {
await page.route("**/api/hardware/probe**", (route) =>
route.fulfill({ json: HAILO_HARDWARE }),
route.fulfill({ json: hardware }),
);
}
await page.route("**/api/config/set", async (route) => {
@@ -202,6 +223,39 @@ test.describe("Detection models settings @high", () => {
).not.toBeChecked();
});
test("a device without an index selects the first unit", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "default", devices: ["edgetpu:pci"] },
]);
await openPage(frigateApp);
await expect(
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:0"),
).toBeChecked();
await expect(
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:1"),
).not.toBeChecked();
});
test("a migrated onnx device resolves to the NVIDIA GPU", async ({
frigateApp,
}) => {
await installRoutes(
frigateApp.page,
[{ scene: "default", devices: ["onnx"] }],
false,
[PLUS_MODEL],
ONNX_GPU_HARDWARE,
);
await openPage(frigateApp);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"NVIDIA GeForce RTX 3060 \u2022 3 cameras",
);
});
test("a unit claimed by another model cannot be picked", async ({
frigateApp,
}) => {
@@ -510,7 +564,7 @@ test.describe("Detection models settings @high", () => {
],
true,
HAILO_PLUS_MODELS,
true,
HAILO_HARDWARE,
);
await openPage(frigateApp);
+46 -2
View File
@@ -14,6 +14,17 @@ const NOW = Math.floor(Date.now() / 1000);
// the fixture detector runs at 75.5 ms, above the live warning threshold
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } };
// the fixture has no go2rtc streams, which gives every camera a live view hint
const RESTREAMED = {
go2rtc: {
streams: {
front_door: ["rtsp://x"],
backyard: ["rtsp://x"],
garage: ["rtsp://x"],
},
},
};
const ERROR_NOTICE = {
id: "model_download_failed:yolo/model.onnx",
kind: "model_download_failed",
@@ -51,6 +62,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp,
}) => {
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE],
});
@@ -84,6 +96,7 @@ test.describe("System — Health tab @medium", () => {
for (const action of ["acknowledge", "mute"] as const) {
test(`${action} posts and removes the row`, async ({ frigateApp }) => {
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
notices: [EVENT_NOTICE],
});
@@ -126,7 +139,10 @@ test.describe("System — Health tab @medium", () => {
}
test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await expect(
@@ -329,6 +345,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp,
}) => {
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE],
});
@@ -765,6 +782,7 @@ test.describe("System — Health notices sources @medium", () => {
test("status bar problems stay out of the list", async ({ frigateApp }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({
config: RESTREAMED,
stats: {
service: { retention_unmet: true },
cameras: { front_door: { camera_fps: 0 } },
@@ -829,6 +847,28 @@ test.describe("System — Health notices sources @medium", () => {
).toBeVisible({ timeout: 15_000 });
});
test("a camera without a go2rtc stream gets a live view hint", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { go2rtc: { streams: { front_door: ["rtsp://x"] } } },
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"health-problem-config:live:no-go2rtc-stream:camera.backyard",
);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row).toHaveAttribute("data-severity", "info");
await expect(row).toContainText("lower frame rate and no audio");
await expect(
frigateApp.page.getByTestId(
"health-problem-config:live:no-go2rtc-stream:camera.front_door",
),
).toHaveCount(0);
});
test("a global config problem is not repeated per camera", async ({
frigateApp,
}) => {
@@ -893,7 +933,10 @@ test.describe("System — Health notices sources @medium", () => {
test("empty state when stats, config, and registry are clean", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await expect(
@@ -1130,6 +1173,7 @@ test.describe("System — Health notices sources @medium", () => {
}) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
notices: [EVENT_NOTICE],
});
@@ -1997,6 +1997,9 @@
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
},
"live": {
"noGo2rtcStream": "Live view for this camera is using a basic player with a lower frame rate and no audio. Set up a go2rtc stream for this camera to get smoother video and audio."
},
"audio": {
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
},
+45
View File
@@ -1,3 +1,5 @@
import { baseUrl } from "./baseUrl";
// Module-level flag to prevent multiple simultaneous redirects
// (eg, when multiple SWR queries fail with 401 at once, or when
// both ApiProvider and ProtectedRoute try to redirect)
@@ -10,3 +12,46 @@ export function isRedirectingToLogin(): boolean {
export function setRedirectingToLogin(value: boolean): void {
_isRedirectingToLogin = value;
}
const REDIRECT_PARAM = "redirect";
// Build the login page url, carrying the current location along
// so the user can be returned to it after logging in
export function getLoginUrl(loginPage: string = `${baseUrl}login`): string {
const url = new URL(loginPage, window.location.href);
// an external login page (eg, an auth proxy) handles its own return url
if (url.origin !== window.location.origin) {
return loginPage;
}
const { pathname, search, hash } = window.location;
const current = `${pathname}${search}${hash}`;
// no need to carry the home page, it is the default after logging in
if (current !== new URL(baseUrl).pathname) {
url.searchParams.set(REDIRECT_PARAM, current);
}
return url.href;
}
// Resolve where to send the user after a successful login
export function getPostLoginUrl(): string {
const target = new URLSearchParams(window.location.search).get(
REDIRECT_PARAM,
);
if (!target) {
return baseUrl;
}
// only return to urls on this origin so the param can't be used
// to send the user to another site
try {
const url = new URL(target, window.location.origin);
return url.origin === window.location.origin ? url.href : baseUrl;
} catch {
return baseUrl;
}
}
+6 -2
View File
@@ -3,7 +3,11 @@ import { SWRConfig } from "swr";
import { WsProvider } from "./WsProvider";
import axios from "axios";
import { ReactNode } from "react";
import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect";
import {
getLoginUrl,
isRedirectingToLogin,
setRedirectingToLogin,
} from "./auth-redirect";
axios.defaults.baseURL = `${baseUrl}api/`;
@@ -34,7 +38,7 @@ export function ApiProvider({ children, options }: ApiProviderType) {
const loginPage = error.response.headers.get("location") ?? "login";
if (window.location.href !== loginPage && !isRedirectingToLogin()) {
setRedirectingToLogin(true);
window.location.href = loginPage;
window.location.href = getLoginUrl(loginPage);
}
}
},
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { baseUrl } from "../../api/baseUrl";
import { getPostLoginUrl } from "@/api/auth-redirect";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -69,7 +69,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
username: profileRes.data.username,
role: profileRes.data.role || "viewer",
});
window.location.href = baseUrl;
window.location.href = getPostLoginUrl();
} catch (error) {
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
+2 -2
View File
@@ -3,10 +3,10 @@ import { Navigate, Outlet } from "react-router-dom";
import { AuthContext } from "@/context/auth-context";
import ActivityIndicator from "../indicators/activity-indicator";
import {
getLoginUrl,
isRedirectingToLogin,
setRedirectingToLogin,
} from "@/api/auth-redirect";
import { baseUrl } from "@/api/baseUrl";
export default function ProtectedRoute({
requiredRoles,
@@ -25,7 +25,7 @@ export default function ProtectedRoute({
!isRedirectingToLogin()
) {
setRedirectingToLogin(true);
window.location.href = `${baseUrl}login`;
window.location.href = getLoginUrl();
}
}, [auth.isLoading, auth.isAuthenticated, auth.user]);
@@ -1,8 +1,24 @@
import { isRestreamedStream } from "@/utils/liveTranscode";
import type { SectionConfigOverrides } from "./types";
const live: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/live",
messages: [
{
key: "no-go2rtc-stream",
health: true,
messageKey: "configMessages.live.noGo2rtcStream",
severity: "info",
docLink: "/configuration/live",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
return !Object.values(ctx.fullCameraConfig.live.streams).some(
(name) => isRestreamedStream(ctx.fullConfig, name),
);
},
},
],
restartRequired: [],
fieldOrder: ["streams", "transcode", "height", "quality"],
fieldGroups: {},
@@ -15,6 +15,7 @@ import {
hardwareForDevices,
MAX_DETECTORS,
recommendedDetectorCount,
resolveUnitDevice,
} from "@/utils/detectionHardware";
type HardwarePickerProps = {
@@ -60,9 +61,13 @@ export function HardwarePicker({
return [];
}
const assigned = devices.map((device) =>
resolveUnitDevice(selected, device),
);
return selected.units
.map((unit) => unit.device)
.filter((device) => devices.includes(device));
.filter((device) => assigned.includes(device));
}, [selected, devices]);
/** Spread `count` detectors round robin over the selected units. */
@@ -200,7 +205,7 @@ export function HardwarePicker({
<Checkbox
id={`${idPrefix}-${unit.device}`}
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={devices.includes(unit.device)}
checked={selectedUnits.includes(unit.device)}
disabled={disabled || Boolean(claimedBy)}
onCheckedChange={(checked) =>
handleUnitToggle(unit.device, checked === true)
+25 -4
View File
@@ -21,10 +21,31 @@ export function hardwareForDevices(
return undefined;
}
return hardware.find((entry) => {
const known = new Set(entry.units.map((unit) => unit.device));
return devices.every((device) => known.has(device));
});
return hardware.find((entry) =>
devices.every((device) => resolveUnitDevice(entry, device)),
);
}
/**
* The unit device string a configured device refers to.
*
* A config may leave the index off, such as "edgetpu:usb", which the detector
* resolves to the first unit of that kind.
*/
export function resolveUnitDevice(
entry: DetectionHardware,
device: string,
): string | undefined {
const unitDevices = entry.units.map((unit) => unit.device);
return (
unitDevices.find((candidate) => candidate === device) ??
unitDevices.find(
(candidate) =>
candidate.startsWith(`${device}:`) ||
candidate.startsWith(`${device}.`),
)
);
}
/**