Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 9ef7c22777 Update scikit-build requirement in /docker/main
Updates the requirements on [scikit-build](https://github.com/scikit-build/scikit-build) to permit the latest version.
- [Release notes](https://github.com/scikit-build/scikit-build/releases)
- [Changelog](https://github.com/scikit-build/scikit-build/blob/main/CHANGES.md)
- [Commits](https://github.com/scikit-build/scikit-build/compare/0.18.0...0.19.1)

---
updated-dependencies:
- dependency-name: scikit-build
  dependency-version: 0.19.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-10 20:15:14 +00:00
84 changed files with 289 additions and 1805 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ peewee_migrate == 2.3.*
psutil == 7.2.* psutil == 7.2.*
pydantic == 2.13.* pydantic == 2.13.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml git+https://github.com/fbcotter/py3nvml#egg=py3nvml
tzdata == 2026.5 pytz == 2026.*
pyzmq == 27.2.* pyzmq == 27.2.*
ruamel.yaml == 0.19.* ruamel.yaml == 0.19.*
tzlocal == 5.4.4 tzlocal == 5.4.4
+1 -1
View File
@@ -1 +1 @@
scikit-build == 0.18.* scikit-build == 0.19.*
@@ -8,13 +8,13 @@ from typing import Any
from ruamel.yaml import YAML from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate") sys.path.insert(0, "/opt/frigate")
from frigate.config.env import apply_config_env_vars, substitute_frigate_vars
from frigate.const import ( from frigate.const import (
BIRDSEYE_PIPE, BIRDSEYE_PIPE,
LIBAVFORMAT_VERSION_MAJOR, LIBAVFORMAT_VERSION_MAJOR,
) )
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
from frigate.util.config import find_config_file, resolve_ffmpeg_path from frigate.util.config import find_config_file, resolve_ffmpeg_path
from frigate.util.env import apply_config_env_vars, substitute_frigate_vars
from frigate.util.live_streams import raw_transcode_streams from frigate.util.live_streams import raw_transcode_streams
from frigate.util.services import ( from frigate.util.services import (
is_go2rtc_arbitrary_exec_allowed, is_go2rtc_arbitrary_exec_allowed,
-8
View File
@@ -53,14 +53,6 @@ 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. 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 ### 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. 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 required: false
schema: schema:
type: string type: string
default: UTC default: utc
title: Timezone title: Timezone
responses: responses:
'200': '200':
@@ -3266,7 +3266,7 @@ paths:
anyOf: anyOf:
- type: string - type: string
- type: 'null' - type: 'null'
default: UTC default: utc
title: Timezone title: Timezone
responses: responses:
'200': '200':
@@ -4666,7 +4666,7 @@ paths:
anyOf: anyOf:
- type: string - type: string
- type: 'null' - type: 'null'
default: UTC default: utc
title: Timezone title: Timezone
responses: responses:
'200': '200':
@@ -4928,7 +4928,7 @@ paths:
anyOf: anyOf:
- type: string - type: string
- type: 'null' - type: 'null'
default: UTC default: utc
title: Timezone title: Timezone
- name: min_score - name: min_score
in: query in: query
@@ -5008,7 +5008,7 @@ paths:
anyOf: anyOf:
- type: string - type: string
- type: 'null' - type: 'null'
default: UTC default: utc
title: Timezone title: Timezone
- name: has_clip - name: has_clip
in: query in: query
@@ -7367,7 +7367,7 @@ paths:
required: false required: false
schema: schema:
type: string type: string
default: UTC default: utc
title: Timezone title: Timezone
- name: cameras - name: cameras
in: query in: query
@@ -7417,7 +7417,7 @@ paths:
required: false required: false
schema: schema:
type: string type: string
default: UTC default: utc
title: Timezone title: Timezone
responses: responses:
'200': '200':
+1 -1
View File
@@ -33,11 +33,11 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.config.env import UnknownVariableError, substitute_frigate_vars
from frigate.models import User from frigate.models import User
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file from frigate.util.config import find_config_file
from frigate.util.env import UnknownVariableError, substitute_frigate_vars
from frigate.util.image import run_ffmpeg_snapshot from frigate.util.image import run_ffmpeg_snapshot
from frigate.util.live_streams import ( from frigate.util.live_streams import (
generated_transcode_streams, generated_transcode_streams,
+1 -8
View File
@@ -1,6 +1,5 @@
"""Object classification APIs.""" """Object classification APIs."""
import asyncio
import datetime import datetime
import logging import logging
import os import os
@@ -49,9 +48,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.classification]) 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: def invalid_name_response(value: str) -> JSONResponse:
"""Response for a name that cannot be used as a path component.""" """Response for a name that cannot be used as a path component."""
@@ -1294,10 +1290,7 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
if model_name is None: if model_name is None:
return invalid_name_response(body.model_name) return invalid_name_response(body.model_name)
async with example_collection_lock: collect_object_classification_examples(model_name, body.label)
await asyncio.to_thread(
collect_object_classification_examples, model_name, body.label
)
return JSONResponse( return JSONResponse(
content={"success": True, "message": "Example generation completed"}, content={"success": True, "message": "Example generation completed"},
@@ -7,4 +7,4 @@ class AppTimelineHourlyQueryParameters(BaseModel):
after: float | None = None after: float | None = None
before: float | None = None before: float | None = None
limit: int | None = 200 limit: int | None = 200
timezone: str | None = "UTC" timezone: str | None = "utc"
@@ -40,7 +40,7 @@ class EventsQueryParams(BaseModel):
max_length: float | None = None max_length: float | None = None
event_id: str | None = None event_id: str | None = None
sort: str | None = None sort: str | None = None
timezone: str | None = "UTC" timezone: str | None = "utc"
class EventsSearchQueryParams(BaseModel): class EventsSearchQueryParams(BaseModel):
@@ -68,7 +68,7 @@ class EventsSearchQueryParams(BaseModel):
has_clip: bool | None = None has_clip: bool | None = None
has_snapshot: bool | None = None has_snapshot: bool | None = None
is_submitted: bool | None = None is_submitted: bool | None = None
timezone: str | None = "UTC" timezone: str | None = "utc"
min_score: float | None = None min_score: float | None = None
max_score: float | None = None max_score: float | None = None
min_speed: float | None = None min_speed: float | None = None
@@ -78,6 +78,6 @@ class EventsSearchQueryParams(BaseModel):
class EventsSummaryQueryParams(BaseModel): class EventsSummaryQueryParams(BaseModel):
timezone: str | None = "UTC" timezone: str | None = "utc"
has_clip: int | None = None has_clip: int | None = None
has_snapshot: int | None = None has_snapshot: int | None = None
@@ -3,7 +3,7 @@ from pydantic.json_schema import SkipJsonSchema
class MediaRecordingsSummaryQueryParams(BaseModel): class MediaRecordingsSummaryQueryParams(BaseModel):
timezone: str = "UTC" timezone: str = "utc"
cameras: str | None = "all" cameras: str | None = "all"
@@ -19,7 +19,7 @@ class ReviewSummaryQueryParams(BaseModel):
cameras: str = "all" cameras: str = "all"
labels: str = "all" labels: str = "all"
zones: str = "all" zones: str = "all"
timezone: str = "UTC" timezone: str = "utc"
class ReviewActivityMotionQueryParams(BaseModel): class ReviewActivityMotionQueryParams(BaseModel):
+9 -12
View File
@@ -1,7 +1,6 @@
import asyncio import asyncio
import logging import logging
import re import re
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -82,20 +81,9 @@ def create_fastapi_app(
config_holder: ConfigHolder | None = None, config_holder: ConfigHolder | None = None,
notice_registry: NoticeRegistry | 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") logger.info("Starting FastAPI app")
app = FastAPI( app = FastAPI(
debug=False, debug=False,
lifespan=lifespan,
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"}, swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
dependencies=[Depends(require_admin_by_default())] dependencies=[Depends(require_admin_by_default())]
if enforce_default_admin if enforce_default_admin
@@ -131,6 +119,15 @@ def create_fastapi_app(
database.close() database.close()
return response 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) # Rate limiter (used for login endpoint)
if frigate_config.auth.failed_login_rate_limit is None: if frigate_config.auth.failed_login_rate_limit is None:
limiter.enabled = False limiter.enabled = False
+2 -5
View File
@@ -14,10 +14,10 @@ from enum import Enum
from pathlib import Path as FilePath from pathlib import Path as FilePath
from typing import IO, Any from typing import IO, Any
from urllib.parse import unquote from urllib.parse import unquote
from zoneinfo import ZoneInfo
import cv2 import cv2
import numpy as np import numpy as np
import pytz
from fastapi import APIRouter, Depends, Path, Query, Request, Response from fastapi import APIRouter, Depends, Path, Query, Request, Response
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
@@ -64,7 +64,6 @@ from frigate.util.recording_coverage import (
plan_clip, plan_clip,
resolve_coverage, resolve_coverage,
) )
from frigate.util.time import get_normalized_tz_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -838,9 +837,7 @@ async def vod_hour(
parts = year_month.split("-") parts = year_month.split("-")
start_date = ( start_date = (
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC) datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC)
- datetime.now( - datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
).utcoffset()
) )
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp() start_ts = start_date.timestamp()
+2 -5
View File
@@ -5,8 +5,8 @@ import logging
import os import os
import threading import threading
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
import pytz
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -22,7 +22,6 @@ from frigate.api.defs.response.preview_response import (
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
from frigate.models import Previews from frigate.models import Previews
from frigate.util.time import get_normalized_tz_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -127,9 +126,7 @@ def preview_hour(
parts = year_month.split("-") parts = year_month.split("-")
start_date = ( start_date = (
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC) datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC)
- datetime.now( - datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
).utcoffset()
) )
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp() start_ts = start_date.timestamp()
+1 -1
View File
@@ -175,7 +175,7 @@ async def all_recordings_summary(
@router.get( @router.get(
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)] "/{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""" """Returns hourly summary for recordings of given camera"""
time_range_query = ( time_range_query = (
-4
View File
@@ -169,10 +169,6 @@ class CameraMaintainer(threading.Thread):
frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1] frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1]
self.frame_manager.create(f"{config.name}_frame{i}", frame_size) 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( capture_process = CameraCapture(
config, config,
count, count,
+8 -5
View File
@@ -349,7 +349,7 @@ class CameraState:
return return
current_frame = self.frame_manager.get( current_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv, frame_time frame_name, self.camera_config.frame_shape_yuv
) )
tracked_objects = self.tracked_objects.copy() tracked_objects = self.tracked_objects.copy()
@@ -404,7 +404,7 @@ class CameraState:
self.send_mqtt_snapshot(new_obj, object_type) self.send_mqtt_snapshot(new_obj, object_type)
for c in self.callbacks["start"]: for c in self.callbacks["start"]:
c(self.name, new_obj, frame_name, frame_time) c(self.name, new_obj, frame_name)
for id in updated_ids: for id in updated_ids:
updated_obj = tracked_objects[id] updated_obj = tracked_objects[id]
@@ -416,7 +416,7 @@ class CameraState:
if autotracker_update or significant_update: if autotracker_update or significant_update:
for c in self.callbacks["autotrack"]: for c in self.callbacks["autotrack"]:
c(self.name, updated_obj, frame_name, frame_time) c(self.name, updated_obj, frame_name)
if thumb_update and current_frame is not None: if thumb_update and current_frame is not None:
# ensure this frame is stored in the cache # ensure this frame is stored in the cache
@@ -466,7 +466,7 @@ class CameraState:
): ):
# call event handlers # call event handlers
for c in self.callbacks["update"]: for c in self.callbacks["update"]:
c(self.name, updated_obj, frame_name, frame_time) c(self.name, updated_obj, frame_name)
updated_obj.last_published = frame_time updated_obj.last_published = frame_time
# send MQTT snapshot when object first enters a required zone, # send MQTT snapshot when object first enters a required zone,
@@ -488,7 +488,7 @@ class CameraState:
removed_obj.obj_data["end_time"] = frame_time removed_obj.obj_data["end_time"] = frame_time
logger.debug(f"{self.name}: end callback for object {id}") logger.debug(f"{self.name}: end callback for object {id}")
for c in self.callbacks["end"]: for c in self.callbacks["end"]:
c(self.name, removed_obj, frame_name, frame_time) c(self.name, removed_obj, frame_name)
# TODO: can i switch to looking this up and only changing when an event ends? # TODO: can i switch to looking this up and only changing when an event ends?
# maintain best objects # maintain best objects
@@ -603,6 +603,9 @@ class CameraState:
self.current_frame_time = frame_time self.current_frame_time = frame_time
self._current_frame = np.copy(current_frame) 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 self.previous_frame_id = frame_name
def send_mqtt_snapshot(self, new_obj: TrackedObject, object_type: str) -> None: def send_mqtt_snapshot(self, new_obj: TrackedObject, object_type: str) -> None:
+2 -18
View File
@@ -8,16 +8,7 @@ from .zmq_proxy import Publisher, Subscriber
class EventUpdatePublisher( class EventUpdatePublisher(
Publisher[ Publisher[tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]]]
tuple[
EventTypeEnum,
EventStateEnum,
str | None,
str,
dict[str, Any],
float | None,
]
]
): ):
"""Publishes events (objects, audio, manual).""" """Publishes events (objects, audio, manual)."""
@@ -28,14 +19,7 @@ class EventUpdatePublisher(
def publish( def publish(
self, self,
payload: tuple[ payload: tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]],
EventTypeEnum,
EventStateEnum,
str | None,
str,
dict[str, Any],
float | None,
],
sub_topic: str = "", sub_topic: str = "",
) -> None: ) -> None:
super().publish(payload, sub_topic) super().publish(payload, sub_topic)
+4 -82
View File
@@ -3,9 +3,6 @@
import errno import errno
import json import json
import logging import logging
import queue
import socket
import struct
import threading import threading
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
@@ -79,12 +76,6 @@ _WS_VIEWER_TOPICS = frozenset(
# Camera-scoped command topics a camera-authorized (non-admin) user may send. # Camera-scoped command topics a camera-authorized (non-admin) user may send.
_WS_CAMERA_COMMAND_TOPICS = frozenset({"ptz"}) _WS_CAMERA_COMMAND_TOPICS = frozenset({"ptz"})
# Max outbound messages waiting on a client's writer thread.
WS_MAX_PENDING_MESSAGES = 256
# Seconds a single send() may wait for a client to free socket buffer space.
WS_SEND_TIMEOUT = 10
def _check_ws_authorization( def _check_ws_authorization(
topic: str, topic: str,
@@ -473,78 +464,6 @@ def _materialize_for_ws(
class WebSocket(WebSocket_): # type: ignore[misc] class WebSocket(WebSocket_): # type: ignore[misc]
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._send_queue: queue.Queue[tuple[Any, bool] | None] = queue.Queue(
maxsize=WS_MAX_PENDING_MESSAGES
)
self._writer: threading.Thread | None = None
self._aborted = False
def opened(self) -> None:
"""Set a send timeout on the socket and start this client's writer thread."""
# ws4py writes close and pong frames on the shared manager thread, so a
# stalled client must not block sendall forever. SO_SNDTIMEO leaves recv
# (and idle clients) untouched, unlike settimeout().
self.sock.setsockopt(
socket.SOL_SOCKET, socket.SO_SNDTIMEO, struct.pack("ll", WS_SEND_TIMEOUT, 0)
)
# every client gets its own writer so a client that stops reading only
# blocks itself, never the thread that called publish()
self._writer = threading.Thread(
target=self._drain_send_queue, name="ws_writer", daemon=True
)
self._writer.start()
def send(self, payload: Any, binary: bool = False) -> None:
"""Queue a message for this client's writer thread.
Returns without waiting for delivery. Disconnects the client if its queue
is full.
"""
try:
self._send_queue.put_nowait((payload, binary))
except queue.Full:
self._abort("Websocket client is not keeping up, disconnecting it")
def _drain_send_queue(self) -> None:
while True:
item = self._send_queue.get()
if item is None or self.terminated or self.sock is None:
return
try:
super().send(*item)
except Exception:
self._abort()
return
def _abort(self, reason: str | None = None) -> None:
# publish() keeps hitting a full queue until the manager thread removes
# the connection, so only act (and log) the first time
if self._aborted:
return
self._aborted = True
if reason:
logger.warning(reason)
# shutdown rather than close so the ws4py manager thread sees EOF and
# runs its normal unregister/terminate; this also unblocks a stuck sendall
sock = self.sock
if sock is not None:
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
def closed(self, code: int, reason: str | None = None) -> None:
"""Signal the writer thread to exit."""
try:
self._send_queue.put_nowait(None)
except queue.Full:
pass
def unhandled_error(self, error: Any) -> None: def unhandled_error(self, error: Any) -> None:
""" """
Handles the unfriendly socket closures on the server side Handles the unfriendly socket closures on the server side
@@ -678,7 +597,10 @@ class WebSocketClient(Communicator):
) )
if message is None: if message is None:
continue continue
ws.send(message) try:
ws.send(message)
except (ConnectionResetError, BrokenPipeError, ValueError):
pass
def stop(self) -> None: def stop(self) -> None:
if self.websocket_server is not None: if self.websocket_server is not None:
+1 -1
View File
@@ -3,9 +3,9 @@ from enum import Enum
from pydantic import Field, field_validator from pydantic import Field, field_validator
from frigate.util.config import resolve_ffmpeg_path from frigate.util.config import resolve_ffmpeg_path
from frigate.util.env import EnvString
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = [ __all__ = [
"CameraFfmpegConfig", "CameraFfmpegConfig",
+1 -2
View File
@@ -3,9 +3,8 @@ from typing import Any, Self
from pydantic import Field, model_validator from pydantic import Field, model_validator
from frigate.util.env import EnvString
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["GenAIConfig", "GenAIProviderEnum", "GenAIRoleEnum"] __all__ = ["GenAIConfig", "GenAIProviderEnum", "GenAIRoleEnum"]
+1 -2
View File
@@ -1,8 +1,7 @@
from pydantic import Field from pydantic import Field
from frigate.util.env import EnvString
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["NotificationConfig"] __all__ = ["NotificationConfig"]
+1 -2
View File
@@ -2,9 +2,8 @@ from enum import Enum
from pydantic import Field, field_validator from pydantic import Field, field_validator
from frigate.util.env import EnvString
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from ..env import EnvString
from .objects import DEFAULT_TRACKED_OBJECTS from .objects import DEFAULT_TRACKED_OBJECTS
__all__ = ["OnvifConfig", "PtzAutotrackConfig", "ZoomingModeEnum"] __all__ = ["OnvifConfig", "PtzAutotrackConfig", "ZoomingModeEnum"]
+1 -1
View File
@@ -34,7 +34,6 @@ from frigate.util.config import (
get_relative_coordinates, get_relative_coordinates,
migrate_frigate_config, migrate_frigate_config,
) )
from frigate.util.env import EnvVars, reload_sources
from frigate.util.image import create_mask from frigate.util.image import create_mask
from frigate.util.live_streams import ( from frigate.util.live_streams import (
default_transcode_source, default_transcode_source,
@@ -72,6 +71,7 @@ from .classification import (
SemanticSearchModelEnum, SemanticSearchModelEnum,
) )
from .database import DatabaseConfig from .database import DatabaseConfig
from .env import EnvVars, reload_sources
from .logger import LoggerConfig from .logger import LoggerConfig
from .mqtt import MqttConfig from .mqtt import MqttConfig
from .network import NetworkingConfig from .network import NetworkingConfig
+1 -1
View File
@@ -3,9 +3,9 @@ from typing import Self
from pydantic import Field, ValidationInfo, model_validator from pydantic import Field, ValidationInfo, model_validator
from frigate.const import FREQUENCY_STATS_POINTS from frigate.const import FREQUENCY_STATS_POINTS
from frigate.util.env import EnvString
from .base import FrigateBaseModel from .base import FrigateBaseModel
from .env import EnvString
__all__ = ["MqttConfig"] __all__ = ["MqttConfig"]
+1 -2
View File
@@ -1,8 +1,7 @@
from pydantic import Field, field_validator from pydantic import Field, field_validator
from frigate.util.env import EnvString
from .base import FrigateBaseModel from .base import FrigateBaseModel
from .env import EnvString
__all__ = ["ProxyConfig", "HeaderMappingConfig"] __all__ = ["ProxyConfig", "HeaderMappingConfig"]
-1
View File
@@ -8,7 +8,6 @@ MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache"
BASE_DIR = "/media/frigate" BASE_DIR = "/media/frigate"
CLIPS_DIR = f"{BASE_DIR}/clips" CLIPS_DIR = f"{BASE_DIR}/clips"
EXPORT_DIR = f"{BASE_DIR}/exports" EXPORT_DIR = f"{BASE_DIR}/exports"
EXPORT_STAGING_DIR = f"{EXPORT_DIR}/staging"
FACE_DIR = f"{CLIPS_DIR}/faces" FACE_DIR = f"{CLIPS_DIR}/faces"
THUMB_DIR = f"{CLIPS_DIR}/thumbs" THUMB_DIR = f"{CLIPS_DIR}/thumbs"
RECORD_DIR = f"{BASE_DIR}/recordings" RECORD_DIR = f"{BASE_DIR}/recordings"
+10 -28
View File
@@ -89,46 +89,30 @@ def is_arm64_platform() -> bool:
return machine in ("aarch64", "arm64", "armv8", "armv7l") return machine in ("aarch64", "arm64", "armv8", "armv7l")
def get_ort_session_options( def get_ort_session_options(model_type: str | None = None) -> ort.SessionOptions | None:
model_type: str | None = None, cpu: bool = False
) -> ort.SessionOptions | None:
"""Get ONNX Runtime session options with appropriate settings. """Get ONNX Runtime session options with appropriate settings.
Args: Args:
model_type: Model being loaded, used to pin its graph optimization level. model_type: Model being loaded, used to pin its graph optimization level.
cpu: Whether the session runs on the CPU execution provider.
Returns: Returns:
SessionOptions for enrichment models, or None for default settings. SessionOptions with a pinned optimization level, or None for default settings.
""" """
# Import here to avoid circular imports # Import here to avoid circular imports
from frigate.embeddings.types import ( from frigate.embeddings.types import EnrichmentModelTypeEnum
ENRICHMENT_FOR_MODEL_TYPE,
EnrichmentModelTypeEnum,
)
if model_type not in ENRICHMENT_FOR_MODEL_TYPE:
return None
sess_options = ort.SessionOptions()
if cpu:
# idle intra-op threads otherwise spin for ~1s after every run, keeping
# most cores busy while an enrichment runs even once a second
sess_options.add_session_config_entry("session.intra_op.allow_spinning", "0")
if model_type == EnrichmentModelTypeEnum.jina_v2.value: if model_type == EnrichmentModelTypeEnum.jina_v2.value:
# below EXTENDED the CUDA EP returns an identical vector for every image, # below EXTENDED the CUDA EP returns an identical vector for every image,
# and ORT_ENABLE_ALL fails to build on CPU with a SimplifiedLayerNormFusion error # and ORT_ENABLE_ALL fails to build on CPU with a SimplifiedLayerNormFusion error
sess_options.graph_optimization_level = ( level = ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED
ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED
)
elif model_type == EnrichmentModelTypeEnum.jina_v1.value: elif model_type == EnrichmentModelTypeEnum.jina_v1.value:
# aggressive optimizations create or expect nodes that don't exist # aggressive optimizations create or expect nodes that don't exist
sess_options.graph_optimization_level = ( level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
ort.GraphOptimizationLevel.ORT_ENABLE_BASIC else:
) return None
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = level
return sess_options return sess_options
@@ -805,9 +789,7 @@ def get_optimized_runner(
ONNXModelRunner( ONNXModelRunner(
ort.InferenceSession( ort.InferenceSession(
model_path, model_path,
sess_options=get_ort_session_options( sess_options=get_ort_session_options(model_type),
model_type, cpu=providers[0] == "CPUExecutionProvider"
),
providers=providers, providers=providers,
provider_options=options, provider_options=options,
), ),
-6
View File
@@ -10,7 +10,6 @@ from json.decoder import JSONDecodeError
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from typing import Any from typing import Any
import cv2
import regex import regex
from pathvalidate import ValidationError, sanitize_filename from pathvalidate import ValidationError, sanitize_filename
@@ -52,11 +51,6 @@ class EmbeddingProcess(FrigateProcess):
from .maintainer import EmbeddingMaintainer from .maintainer import EmbeddingMaintainer
self.pre_run_setup(self.config.logger) self.pre_run_setup(self.config.logger)
# frame conversions and crops here are one-off calls, and OpenCV's
# per-core thread pool burns ~8x the CPU of running them inline
cv2.setNumThreads(1)
maintainer = EmbeddingMaintainer( maintainer = EmbeddingMaintainer(
self.config, self.config,
self.metrics, self.metrics,
+8 -7
View File
@@ -332,9 +332,6 @@ class EmbeddingMaintainer(threading.Thread):
"""Apply camera config updates and register newly enabled processors.""" """Apply camera config updates and register newly enabled processors."""
updated_topics = self.config_updater.check_for_updates() 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: if updated_topics.keys() & GENAI_UPDATE_TOPICS:
self._sync_genai_processors() self._sync_genai_processors()
@@ -520,7 +517,7 @@ class EmbeddingMaintainer(threading.Thread):
if update is None: if update is None:
return return
source_type, event_type, camera, frame_name, data, frame_time = update source_type, event_type, camera, frame_name, data = update
logger.debug( logger.debug(
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}" f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
@@ -549,7 +546,7 @@ class EmbeddingMaintainer(threading.Thread):
# Create our own thumbnail based on the bounding box and the frame time # Create our own thumbnail based on the bounding box and the frame time
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time frame_name, camera_config.frame_shape_yuv
) )
except FileNotFoundError: except FileNotFoundError:
logger.debug(f"Frame {frame_name} not found for camera {camera}") logger.debug(f"Frame {frame_name} not found for camera {camera}")
@@ -586,6 +583,8 @@ class EmbeddingMaintainer(threading.Thread):
PostProcessDataEnum.tracked_object, PostProcessDataEnum.tracked_object,
) )
self.frame_manager.close(frame_name)
def _process_finalized(self) -> None: def _process_finalized(self) -> None:
"""Process the end of an event.""" """Process the end of an event."""
while True: while True:
@@ -794,7 +793,7 @@ class EmbeddingMaintainer(threading.Thread):
if topic is None: if topic is None:
return return
camera, frame_name, frame_time, _, motion_boxes, _ = data camera, frame_name, _, _, motion_boxes, _ = data
if not camera or camera not in self.config.cameras: if not camera or camera not in self.config.cameras:
return return
@@ -818,7 +817,7 @@ class EmbeddingMaintainer(threading.Thread):
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time frame_name, camera_config.frame_shape_yuv
) )
except FileNotFoundError: except FileNotFoundError:
pass pass
@@ -842,6 +841,8 @@ class EmbeddingMaintainer(threading.Thread):
{"camera": camera, "motion": motion_boxes}, yuv_frame {"camera": camera, "motion": motion_boxes}, yuv_frame
) )
self.frame_manager.close(frame_name)
def _process_deferred_results(self) -> None: def _process_deferred_results(self) -> None:
"""Drain results from deferred processors and perform IPC side-effects.""" """Drain results from deferred processors and perform IPC side-effects."""
for processor in self.realtime_processors: for processor in self.realtime_processors:
+2 -2
View File
@@ -5,7 +5,7 @@ import os
import shutil import shutil
import threading import threading
from transformers import AutoTokenizer, CLIPImageProcessorPil from transformers import AutoTokenizer, CLIPImageProcessor
from transformers.utils.logging import disable_progress_bar from transformers.utils.logging import disable_progress_bar
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
@@ -209,7 +209,7 @@ class JinaV1ImageEmbedding(BaseEmbedding):
if self.downloader: if self.downloader:
self.downloader.wait_for_download() self.downloader.wait_for_download()
self.feature_extractor = CLIPImageProcessorPil.from_pretrained( self.feature_extractor = CLIPImageProcessor.from_pretrained(
f"{MODEL_CACHE_DIR}/{self.model_name}", f"{MODEL_CACHE_DIR}/{self.model_name}",
) )
+1 -1
View File
@@ -94,7 +94,7 @@ class EventProcessor(threading.Thread):
if update == None: if update == None:
continue continue
source_type, event_type, camera, _, event_data, _ = update source_type, event_type, camera, _, event_data = update
logger.debug( logger.debug(
f"Event received: {source_type} {event_type} {camera} {event_data['id']}" f"Event received: {source_type} {event_type} {camera} {event_data['id']}"
+1 -5
View File
@@ -2,7 +2,6 @@
import logging import logging
import os import os
import shutil
import threading import threading
import time import time
from collections.abc import Callable from collections.abc import Callable
@@ -16,7 +15,7 @@ from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.record import ChaptersEnum from frigate.config.camera.record import ChaptersEnum
from frigate.const import EXPORT_STAGING_DIR, UPDATE_JOB_STATE from frigate.const import UPDATE_JOB_STATE
from frigate.jobs.job import Job from frigate.jobs.job import Job
from frigate.models import Export from frigate.models import Export
from frigate.record.export import ( from frigate.record.export import (
@@ -416,9 +415,6 @@ def reap_stale_exports() -> None:
this in a try/except. A failure on a single row will not stop the rest 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. 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: try:
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712 stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
except Exception: except Exception:
+5
View File
@@ -170,6 +170,7 @@ class DetectorRunner(FrigateProcess):
mono_start = time.monotonic() mono_start = time.monotonic()
detections = object_detector.detect_raw(input_frame) detections = object_detector.detect_raw(input_frame)
duration = time.monotonic() - mono_start duration = time.monotonic() - mono_start
frame_manager.close(connection_id)
if connection_id not in self.outputs: if connection_id not in self.outputs:
self.create_output_shm(connection_id) self.create_output_shm(connection_id)
@@ -258,6 +259,10 @@ class AsyncDetectorRunner(FrigateProcess):
ts = self.send_times.popleft() ts = self.send_times.popleft()
duration = time.perf_counter() - ts 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: if connection_id not in self.outputs:
self.create_output_shm(connection_id) self.create_output_shm(connection_id)
+6 -1
View File
@@ -277,7 +277,12 @@ class BroadcastThread(threading.Thread):
and ws.environ["PATH_INFO"] == f"/{self.camera}" and ws.environ["PATH_INFO"] == f"/{self.camera}"
and ws_has_camera_access(ws, self.camera, self.config) and ws_has_camera_access(ws, self.camera, self.config)
): ):
ws.send(buf, binary=True) try:
ws.send(buf, binary=True)
except ValueError:
pass
except (BrokenPipeError, ConnectionResetError, OSError) as e:
logger.debug(f"Websocket unexpectedly closed {e}")
elif self.converter.process.poll() is not None: elif self.converter.process.poll() is not None:
break break
+6 -1
View File
@@ -127,7 +127,12 @@ class BroadcastThread(threading.Thread):
and ws.environ["PATH_INFO"] == f"/{self.camera}" and ws.environ["PATH_INFO"] == f"/{self.camera}"
and ws_has_camera_access(ws, self.camera, self.config) and ws_has_camera_access(ws, self.camera, self.config)
): ):
ws.send(buf, binary=True) try:
ws.send(buf, binary=True)
except ValueError:
pass
except (BrokenPipeError, ConnectionResetError) as e:
logger.debug(f"Websocket unexpectedly closed {e}")
elif self.converter.process.poll() is not None: elif self.converter.process.poll() is not None:
break break
+18 -11
View File
@@ -8,7 +8,6 @@ import threading
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from wsgiref.simple_server import make_server from wsgiref.simple_server import make_server
import cv2
from ws4py.server.wsgirefserver import ( from ws4py.server.wsgirefserver import (
WebSocketWSGIHandler, WebSocketWSGIHandler,
WebSocketWSGIRequestHandler, WebSocketWSGIRequestHandler,
@@ -123,10 +122,6 @@ class OutputProcess(FrigateProcess):
def run(self) -> None: def run(self) -> None:
self.pre_run_setup(self.config.logger) self.pre_run_setup(self.config.logger)
# birdseye resizes are small, and OpenCV's per-core thread pool burns
# ~3x the CPU of running them inline
cv2.setNumThreads(1)
frame_manager = SharedMemoryFrameManager() frame_manager = SharedMemoryFrameManager()
# start a websocket server on 8082 # start a websocket server on 8082
@@ -150,7 +145,6 @@ class OutputProcess(FrigateProcess):
CameraConfigUpdateEnum.birdseye, CameraConfigUpdateEnum.birdseye,
CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.record, CameraConfigUpdateEnum.record,
CameraConfigUpdateEnum.remove,
], ],
) )
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True) birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
@@ -214,10 +208,6 @@ class OutputProcess(FrigateProcess):
birdseye, birdseye,
) )
if CameraConfigUpdateEnum.remove in updates:
for camera in updates["remove"]:
frame_manager.close_camera(camera)
_result = detection_subscriber.check_for_update(timeout=1) _result = detection_subscriber.check_for_update(timeout=1)
if _result is None: if _result is None:
continue continue
@@ -251,7 +241,7 @@ class OutputProcess(FrigateProcess):
continue continue
frame = frame_manager.get( frame = frame_manager.get(
frame_name, self.config.cameras[camera].frame_shape_yuv, frame_time frame_name, self.config.cameras[camera].frame_shape_yuv
) )
if frame is None: if frame is None:
@@ -306,6 +296,8 @@ class OutputProcess(FrigateProcess):
frame, frame,
) )
frame_manager.close(frame_name)
move_preview_frames("clips") move_preview_frames("clips")
while True: while True:
@@ -317,6 +309,21 @@ class OutputProcess(FrigateProcess):
if not topic or data is None: if not topic or data is None:
break 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() detection_subscriber.stop()
for jsmpeg in jsmpeg_cameras.values(): for jsmpeg in jsmpeg_cameras.values():
+3 -1
View File
@@ -124,7 +124,7 @@ class PtzMotionEstimator:
) )
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv, frame_time frame_name, self.camera_config.frame_shape_yuv
) )
if yuv_frame is None: if yuv_frame is None:
@@ -181,6 +181,8 @@ class PtzMotionEstimator:
except Exception: except Exception:
pass pass
self.frame_manager.close(frame_name)
return self.coord_transformations return self.coord_transformations
+7 -19
View File
@@ -15,8 +15,8 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytz
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
from peewee import DoesNotExist from peewee import DoesNotExist
@@ -26,7 +26,6 @@ from frigate.const import (
CACHE_DIR, CACHE_DIR,
CLIPS_DIR, CLIPS_DIR,
EXPORT_DIR, EXPORT_DIR,
EXPORT_STAGING_DIR,
MAX_PLAYLIST_SECONDS, MAX_PLAYLIST_SECONDS,
PREVIEW_FRAME_TYPE, PREVIEW_FRAME_TYPE,
STREAM_TYPE_MAIN, STREAM_TYPE_MAIN,
@@ -49,7 +48,7 @@ from frigate.util.recording_coverage import (
stream_media_summary, stream_media_summary,
) )
from frigate.util.services import get_video_properties from frigate.util.services import get_video_properties
from frigate.util.time import get_normalized_tz_name, is_current_hour from frigate.util.time import is_current_hour
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -560,7 +559,7 @@ class RecordingExporter(threading.Thread):
) )
def _staged_run_path(self, index: int) -> str: def _staged_run_path(self, index: int) -> str:
return os.path.join(EXPORT_STAGING_DIR, f"{self.export_id}_{index}.mp4") return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4")
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None: def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
"""Probe one recording from a run for its resolution. """Probe one recording from a run for its resolution.
@@ -715,17 +714,6 @@ class RecordingExporter(threading.Thread):
for index, run in enumerate(runs): for index, run in enumerate(runs):
dest = self._staged_run_path(index) 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 weight = 100.0 * run.duration / total_duration
base = 100.0 * completed / total_duration base = 100.0 * completed / total_duration
@@ -874,8 +862,8 @@ class RecordingExporter(threading.Thread):
tz_name = self.config.ui.timezone tz_name = self.config.ui.timezone
if tz_name: if tz_name:
try: try:
tz = ZoneInfo(get_normalized_tz_name(tz_name)) tz = pytz.timezone(tz_name)
except (ValueError, ZoneInfoNotFoundError): except pytz.UnknownTimeZoneError:
tz = None tz = None
if tz is not None: if tz is not None:
return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime( return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime(
@@ -1038,8 +1026,8 @@ class RecordingExporter(threading.Thread):
tz: datetime.tzinfo | None = None tz: datetime.tzinfo | None = None
if tz_name: if tz_name:
try: try:
tz = ZoneInfo(get_normalized_tz_name(tz_name)) tz = pytz.timezone(tz_name)
except (ValueError, ZoneInfoNotFoundError): except pytz.UnknownTimeZoneError:
tz = None tz = None
if tz is None: if tz is None:
tz = datetime.UTC tz = datetime.UTC
+8 -5
View File
@@ -504,7 +504,6 @@ class ReviewSegmentMaintainer(threading.Thread):
def _handle_camera_removed(self, camera: str) -> None: def _handle_camera_removed(self, camera: str) -> None:
"""Close out a deleted camera's segment so a reused name cannot inherit it.""" """Close out a deleted camera's segment so a reused name cannot inherit it."""
self.forcibly_end_segment(camera) self.forcibly_end_segment(camera)
self.frame_manager.close_camera(camera)
self.indefinite_events.pop(camera, None) self.indefinite_events.pop(camera, None)
self.recent_classification_state_changes.pop(camera, None) self.recent_classification_state_changes.pop(camera, None)
@@ -545,7 +544,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time frame_name, camera_config.frame_shape_yuv
) )
except FileNotFoundError: except FileNotFoundError:
return return
@@ -555,6 +554,7 @@ class ReviewSegmentMaintainer(threading.Thread):
return return
pending.update_frame(camera_config, yuv_frame, objects) pending.update_frame(camera_config, yuv_frame, objects)
self.frame_manager.close(frame_name)
def update_existing_segment( def update_existing_segment(
self, self,
@@ -653,7 +653,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try: try:
if should_update_image: if should_update_image:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time frame_name, camera_config.frame_shape_yuv
) )
if yuv_frame is None: if yuv_frame is None:
@@ -669,6 +669,7 @@ class ReviewSegmentMaintainer(threading.Thread):
activity.get_all_objects(), activity.get_all_objects(),
prev_data, prev_data,
) )
self.frame_manager.close(frame_name)
except FileNotFoundError: except FileNotFoundError:
return return
@@ -676,7 +677,7 @@ class ReviewSegmentMaintainer(threading.Thread):
if not segment.has_frame: if not segment.has_frame:
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time frame_name, camera_config.frame_shape_yuv
) )
if yuv_frame is None: if yuv_frame is None:
@@ -684,6 +685,7 @@ class ReviewSegmentMaintainer(threading.Thread):
return return
segment.save_full_frame(camera_config, yuv_frame) segment.save_full_frame(camera_config, yuv_frame)
self.frame_manager.close(frame_name)
self._publish_segment_update( self._publish_segment_update(
segment, camera_config, None, [], prev_data segment, camera_config, None, [], prev_data
) )
@@ -768,7 +770,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time frame_name, camera_config.frame_shape_yuv
) )
if yuv_frame is None: if yuv_frame is None:
@@ -778,6 +780,7 @@ class ReviewSegmentMaintainer(threading.Thread):
new_segment.update_frame( new_segment.update_frame(
camera_config, yuv_frame, activity.get_all_objects() camera_config, yuv_frame, activity.get_all_objects()
) )
self.frame_manager.close(frame_name)
self._publish_segment_start(new_segment) self._publish_segment_start(new_segment)
except FileNotFoundError: except FileNotFoundError:
return return
+1 -1
View File
@@ -4,12 +4,12 @@ from unittest.mock import Mock, patch
import frigate.genai import frigate.genai
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.config.env import FRIGATE_ENV_VARS
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.genai import GenAIClient from frigate.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
from frigate.util.env import FRIGATE_ENV_VARS
class TestHttpApp(BaseTestHttp): class TestHttpApp(BaseTestHttp):
-17
View File
@@ -453,23 +453,6 @@ class TestHttpExport(BaseTestHttp):
assert unchanged.name == "front door export" assert unchanged.name == "front door export"
assert unchanged.video_path == video 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): def test_reap_stale_exports_deletes_rows_with_no_file(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
stale_video = os.path.join(tmpdir, "stale.mp4") stale_video = os.path.join(tmpdir, "stale.mp4")
+21 -18
View File
@@ -3,8 +3,8 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from unittest.mock import patch from unittest.mock import patch
from zoneinfo import ZoneInfo
import pytz
from fastapi import Request from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user 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 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) Clocks spring forward from 2:00 AM to 3:00 AM (EST to EDT)
""" """
tz = ZoneInfo("America/New_York") tz = pytz.timezone("America/New_York")
# March 9, 2024 at 12:00 PM EST (before DST) # March 9, 2024 at 12:00 PM EST (before DST)
march_9_noon = datetime(2024, 3, 9, 12, 0, 0, tzinfo=tz).timestamp() march_9_noon = tz.localize(datetime(2024, 3, 9, 12, 0, 0)).timestamp()
# March 10, 2024 at 12:00 PM EDT (after DST transition) # March 10, 2024 at 12:00 PM EDT (after DST transition)
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp() march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
# March 11, 2024 at 12:00 PM EDT (after DST) # March 11, 2024 at 12:00 PM EDT (after DST)
march_11_noon = datetime(2024, 3, 11, 12, 0, 0, tzinfo=tz).timestamp() march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp()
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Insert recordings for each day # Insert recordings for each day
@@ -201,16 +201,19 @@ class TestHttpMedia(BaseTestHttp):
In 2024, DST in America/New_York transitions on November 3, 2024 at 2:00 AM 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) Clocks fall back from 2:00 AM to 1:00 AM (EDT to EST)
""" """
tz = ZoneInfo("America/New_York") tz = pytz.timezone("America/New_York")
# November 2, 2024 at 12:00 PM EDT (before DST transition) # November 2, 2024 at 12:00 PM EDT (before DST transition)
nov_2_noon = datetime(2024, 11, 2, 12, 0, 0, tzinfo=tz).timestamp() nov_2_noon = tz.localize(datetime(2024, 11, 2, 12, 0, 0)).timestamp()
# November 3, 2024 at 12:00 PM EST (after DST transition) # November 3, 2024 at 12:00 PM EST (after DST transition)
nov_3_noon = datetime(2024, 11, 3, 12, 0, 0, tzinfo=tz).timestamp() # 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()
# November 4, 2024 at 12:00 PM EST (after DST) # November 4, 2024 at 12:00 PM EST (after DST)
nov_4_noon = datetime(2024, 11, 4, 12, 0, 0, tzinfo=tz).timestamp() nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp()
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Insert recordings for each day # Insert recordings for each day
@@ -271,13 +274,13 @@ class TestHttpMedia(BaseTestHttp):
""" """
Test recordings summary with multiple cameras across DST boundary. Test recordings summary with multiple cameras across DST boundary.
""" """
tz = ZoneInfo("America/New_York") tz = pytz.timezone("America/New_York")
# March 9, 2024 at 10:00 AM EST (before DST) # March 9, 2024 at 10:00 AM EST (before DST)
march_9_morning = datetime(2024, 3, 9, 10, 0, 0, tzinfo=tz).timestamp() march_9_morning = tz.localize(datetime(2024, 3, 9, 10, 0, 0)).timestamp()
# March 10, 2024 at 3:00 PM EDT (after DST transition) # March 10, 2024 at 3:00 PM EDT (after DST transition)
march_10_afternoon = datetime(2024, 3, 10, 15, 0, 0, tzinfo=tz).timestamp() march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp()
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Override allowed cameras for this test to include both # Override allowed cameras for this test to include both
@@ -340,15 +343,15 @@ class TestHttpMedia(BaseTestHttp):
""" """
Test recordings that span the exact DST transition time. Test recordings that span the exact DST transition time.
""" """
tz = ZoneInfo("America/New_York") tz = pytz.timezone("America/New_York")
# March 10, 2024 at 1:00 AM EST (1 hour before DST transition) # March 10, 2024 at 1:00 AM EST (1 hour before DST transition)
# At 2:00 AM, clocks jump to 3:00 AM # At 2:00 AM, clocks jump to 3:00 AM
before_transition = datetime(2024, 3, 10, 1, 0, 0, tzinfo=tz).timestamp() before_transition = tz.localize(datetime(2024, 3, 10, 1, 0, 0)).timestamp()
# Recording that spans the transition (1:00 AM to 3:30 AM EDT) # 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 # This is 1.5 hours of actual time but spans the "missing" hour
after_transition = datetime(2024, 3, 10, 3, 30, 0, tzinfo=tz).timestamp() after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp()
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
Recordings.insert( Recordings.insert(
@@ -408,7 +411,7 @@ class TestHttpMedia(BaseTestHttp):
# Test with UTC timezone # Test with UTC timezone
response = client.get( response = client.get(
"/recordings/summary", params={"timezone": "UTC", "cameras": "all"} "/recordings/summary", params={"timezone": "utc", "cameras": "all"}
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -439,8 +442,8 @@ class TestHttpMedia(BaseTestHttp):
""" """
Test recordings summary filtered to a single camera. Test recordings summary filtered to a single camera.
""" """
tz = ZoneInfo("America/New_York") tz = pytz.timezone("America/New_York")
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp() march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Insert recordings for both cameras # Insert recordings for both cameras
+1 -1
View File
@@ -342,7 +342,7 @@ class TestHttpReview(BaseTestHttp):
"cameras": "front_door", "cameras": "front_door",
"labels": "all", "labels": "all",
"zones": "all", "zones": "all",
"timezone": "UTC", "timezone": "utc",
} }
response = client.get("/review/summary", params=params) response = client.get("/review/summary", params=params)
assert response.status_code == 200 assert response.status_code == 200
-2
View File
@@ -183,12 +183,10 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}] "deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
} }
maintainer.forcibly_end_segment = MagicMock() maintainer.forcibly_end_segment = MagicMock()
maintainer.frame_manager = MagicMock()
maintainer._handle_camera_removed("deleted_cam") maintainer._handle_camera_removed("deleted_cam")
maintainer.forcibly_end_segment.assert_called_once_with("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.indefinite_events)
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes) self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
@@ -1,33 +0,0 @@
"""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()
+8 -27
View File
@@ -35,34 +35,15 @@ class TestGetOrtSessionOptions(unittest.TestCase):
ort.GraphOptimizationLevel.ORT_ENABLE_BASIC, ort.GraphOptimizationLevel.ORT_ENABLE_BASIC,
) )
def test_cpu_enrichment_models_disable_spinning(self): def test_other_models_use_defaults(self):
for model_type in EnrichmentModelTypeEnum: for model_type in [
None,
EnrichmentModelTypeEnum.paddleocr.value,
EnrichmentModelTypeEnum.arcface.value,
ModelTypeEnum.rfdetr.value,
]:
with self.subTest(model_type=model_type): with self.subTest(model_type=model_type):
options = get_ort_session_options(model_type.value, cpu=True) self.assertIsNone(get_ort_session_options(model_type))
self.assertEqual(
options.get_session_config_entry("session.intra_op.allow_spinning"),
"0",
)
def test_gpu_enrichment_models_keep_spinning(self):
options = get_ort_session_options(EnrichmentModelTypeEnum.arcface.value)
with self.assertRaises(RuntimeError):
options.get_session_config_entry("session.intra_op.allow_spinning")
def test_arcface_keeps_default_optimization(self):
options = get_ort_session_options(EnrichmentModelTypeEnum.arcface.value)
self.assertEqual(
options.graph_optimization_level,
ort.GraphOptimizationLevel.ORT_ENABLE_ALL,
)
def test_detector_models_use_defaults(self):
for model_type in [None, ModelTypeEnum.rfdetr.value]:
with self.subTest(model_type=model_type):
self.assertIsNone(get_ort_session_options(model_type, cpu=True))
class TestCudaGraphRunner(unittest.TestCase): class TestCudaGraphRunner(unittest.TestCase):
+10 -11
View File
@@ -7,9 +7,8 @@ from unittest.mock import MagicMock, patch
from pydantic import ValidationError from pydantic import ValidationError
from frigate.config import FrigateConfig from frigate.config import FrigateConfig, env
from frigate.util import env from frigate.config.env import (
from frigate.util.env import (
FRIGATE_ENV_VARS, FRIGATE_ENV_VARS,
validate_env_string, validate_env_string,
validate_env_vars, validate_env_vars,
@@ -302,14 +301,14 @@ class TestVariableSources(unittest.TestCase):
def test_collision_warns_once_naming_the_winner(self): def test_collision_warns_once_naming_the_winner(self):
"""A key from two sources logs one warning naming the source that won.""" """A key from two sources logs one warning naming the source that won."""
with patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}): with patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}):
with self.assertLogs("frigate.util.env", level="WARNING") as logs: with self.assertLogs("frigate.config.env", level="WARNING") as logs:
env.apply_config_env_vars({"FRIGATE_MQTT_HOST": "from_config"}) env.apply_config_env_vars({"FRIGATE_MQTT_HOST": "from_config"})
self.assertEqual(len(logs.output), 1) self.assertEqual(len(logs.output), 1)
self.assertIn("FRIGATE_MQTT_HOST", logs.output[0]) self.assertIn("FRIGATE_MQTT_HOST", logs.output[0])
self.assertIn("using the value from container environment", logs.output[0]) self.assertIn("using the value from container environment", logs.output[0])
self.assertNotIn("environment_vars", logs.output[0]) self.assertNotIn("environment_vars", logs.output[0])
with self.assertNoLogs("frigate.util.env", level="WARNING"): with self.assertNoLogs("frigate.config.env", level="WARNING"):
env._rebuild() env._rebuild()
@@ -361,7 +360,7 @@ class TestSecretsFile(unittest.TestCase):
def test_unprefixed_key_is_ignored_with_a_warning(self): def test_unprefixed_key_is_ignored_with_a_warning(self):
"""Names must start with FRIGATE_, matching the credentials directory.""" """Names must start with FRIGATE_, matching the credentials directory."""
self._write("cam_pass: hunter2\nFRIGATE_CAM_PASS: hunter2\n") self._write("cam_pass: hunter2\nFRIGATE_CAM_PASS: hunter2\n")
with self.assertLogs("frigate.util.env", level="WARNING") as logs: with self.assertLogs("frigate.config.env", level="WARNING") as logs:
values = env._load_secrets_file() values = env._load_secrets_file()
self.assertEqual(values, {"FRIGATE_CAM_PASS": "hunter2"}) self.assertEqual(values, {"FRIGATE_CAM_PASS": "hunter2"})
self.assertIn("cam_pass", logs.output[0]) self.assertIn("cam_pass", logs.output[0])
@@ -484,10 +483,10 @@ class TestSourceRobustness(unittest.TestCase):
env.reload_sources() env.reload_sources()
env._CONFIG_ENV_VARS["FRIGATE_DUPE"] = "from_config" env._CONFIG_ENV_VARS["FRIGATE_DUPE"] = "from_config"
with self.assertNoLogs("frigate.util.env", level="WARNING"): with self.assertNoLogs("frigate.config.env", level="WARNING"):
env._rebuild(warn=False) env._rebuild(warn=False)
with self.assertLogs("frigate.util.env", level="WARNING") as logs: with self.assertLogs("frigate.config.env", level="WARNING") as logs:
env._rebuild() env._rebuild()
self.assertIn("FRIGATE_DUPE", logs.output[0]) self.assertIn("FRIGATE_DUPE", logs.output[0])
@@ -497,7 +496,7 @@ class TestSourceRobustness(unittest.TestCase):
env.reload_sources() env.reload_sources()
self._write_secrets("FRIGATE_CAMS:\n alley: hunter2\n") self._write_secrets("FRIGATE_CAMS:\n alley: hunter2\n")
with self.assertLogs("frigate.util.env", level="ERROR") as logs: with self.assertLogs("frigate.config.env", level="ERROR") as logs:
env.reload_sources() env.reload_sources()
self.assertIn("FRIGATE_CAMS", logs.output[0]) self.assertIn("FRIGATE_CAMS", logs.output[0])
@@ -506,7 +505,7 @@ class TestSourceRobustness(unittest.TestCase):
def test_duplicate_key_error_does_not_log_the_values(self): def test_duplicate_key_error_does_not_log_the_values(self):
"""ruamel's duplicate key message quotes both values; the log must not.""" """ruamel's duplicate key message quotes both values; the log must not."""
self._write_secrets("FRIGATE_CAM_PASS: hunter2\nFRIGATE_CAM_PASS: hunter3\n") self._write_secrets("FRIGATE_CAM_PASS: hunter2\nFRIGATE_CAM_PASS: hunter3\n")
with self.assertLogs("frigate.util.env", level="ERROR") as logs: with self.assertLogs("frigate.config.env", level="ERROR") as logs:
env.reload_sources() env.reload_sources()
self.assertNotIn("hunter2", logs.output[0]) self.assertNotIn("hunter2", logs.output[0])
@@ -536,7 +535,7 @@ class TestSourceRobustness(unittest.TestCase):
with open(os.path.join(self._creds.name, "FRIGATE_CRED"), "w") as f: with open(os.path.join(self._creds.name, "FRIGATE_CRED"), "w") as f:
f.write("from_creds\n") f.write("from_creds\n")
with self.assertLogs("frigate.util.env", level="WARNING") as logs: with self.assertLogs("frigate.config.env", level="WARNING") as logs:
env.reload_sources() env.reload_sources()
self.assertIn("FRIGATE_NOT_A_FILE", logs.output[0]) self.assertIn("FRIGATE_NOT_A_FILE", logs.output[0])
-11
View File
@@ -653,17 +653,6 @@ class TestStagedFileCleanup(unittest.TestCase):
self.assertEqual(os.listdir(tmpdir), []) 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): class TestStagingFailure(unittest.TestCase):
def test_failed_staging_aborts_rather_than_falling_back(self) -> None: def test_failed_staging_aborts_rather_than_falling_back(self) -> None:
-41
View File
@@ -1,41 +0,0 @@
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
@@ -1,146 +0,0 @@
"""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),
)
+6 -12
View File
@@ -3,7 +3,7 @@ from unittest.mock import MagicMock, Mock, patch
import numpy as np import numpy as np
import zmq import zmq
from pydantic import TypeAdapter from pydantic import parse_obj_as
import frigate.detectors as detectors import frigate.detectors as detectors
import frigate.object_detection.base import frigate.object_detection.base
@@ -20,8 +20,8 @@ class TestLocalObjectDetector(unittest.TestCase):
"frigate.detectors.api_types", "frigate.detectors.api_types",
{det_type: Mock() for det_type in DetectorTypeEnum}, {det_type: Mock() for det_type in DetectorTypeEnum},
): ):
test_cfg = TypeAdapter(DetectorConfig).validate_python( test_cfg = parse_obj_as(
{"type": det_type, "model": {}} DetectorConfig, ({"type": det_type, "model": {}})
) )
test_cfg.model.path = "/test/modelpath" test_cfg.model.path = "/test/modelpath"
test_obj = frigate.object_detection.base.LocalObjectDetector( test_obj = frigate.object_detection.base.LocalObjectDetector(
@@ -45,9 +45,7 @@ class TestLocalObjectDetector(unittest.TestCase):
TEST_DATA = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] TEST_DATA = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32]) TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32])
test_obj_detect = frigate.object_detection.base.LocalObjectDetector( test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
detector_config=TypeAdapter(DetectorConfig).validate_python( detector_config=parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
{"type": "cpu", "model": {}}
)
) )
mock_det_api = mock_cputfl.return_value mock_det_api = mock_cputfl.return_value
@@ -70,9 +68,7 @@ class TestLocalObjectDetector(unittest.TestCase):
TEST_DATA = np.zeros((1, 32, 32, 3), np.uint8) TEST_DATA = np.zeros((1, 32, 32, 3), np.uint8)
TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32]) TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32])
test_cfg = TypeAdapter(DetectorConfig).validate_python( test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
{"type": "cpu", "model": {}}
)
test_cfg.model.input_tensor = InputTensorEnum.nchw test_cfg.model.input_tensor = InputTensorEnum.nchw
test_obj_detect = frigate.object_detection.base.LocalObjectDetector( test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
@@ -121,9 +117,7 @@ class TestLocalObjectDetector(unittest.TestCase):
4: "label-5", 4: "label-5",
} }
test_cfg = TypeAdapter(DetectorConfig).validate_python( test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
{"type": "cpu", "model": {}}
)
test_cfg.model = ModelConfig() test_cfg.model = ModelConfig()
test_obj_detect = frigate.object_detection.base.LocalObjectDetector( test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
detector_config=test_cfg, detector_config=test_cfg,
+1 -1
View File
@@ -4,7 +4,7 @@ from pydantic import ValidationError
from frigate.api.auth import resolve_role from frigate.api.auth import resolve_role
from frigate.config import AuthConfig, HeaderMappingConfig, ProxyConfig from frigate.config import AuthConfig, HeaderMappingConfig, ProxyConfig
from frigate.util.env import FRIGATE_ENV_VARS from frigate.config.env import FRIGATE_ENV_VARS
class TestProxyRoleResolution(unittest.TestCase): class TestProxyRoleResolution(unittest.TestCase):
-17
View File
@@ -13,7 +13,6 @@ from frigate.models import Recordings
from frigate.util.recording_coverage import ( from frigate.util.recording_coverage import (
_rows_query, _rows_query,
coverage_spans, coverage_spans,
null_audio_glitches,
plan_clip, plan_clip,
realized_timeline, realized_timeline,
resolve_coverage, resolve_coverage,
@@ -167,22 +166,6 @@ class TestRecordingCoverage(CoverageDbTestCase):
expected = int(5 * 1024 * 1024 * 8 / 10) expected = int(5 * 1024 * 1024 * 8 / 10)
assert summary["main"]["bitrate"] == expected 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): def test_other_camera_rows_excluded(self):
self._insert("m1", 1000.0, 1010.0, "main") self._insert("m1", 1000.0, 1010.0, "main")
self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard") 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.config_subscriber.check_for_updates.return_value = {}
maintainer.detection_subscriber = MagicMock() maintainer.detection_subscriber = MagicMock()
maintainer.frame_manager = MagicMock() maintainer.frame_manager = MagicMock()
maintainer.frame_manager.get.side_effect = lambda _name, shape, _t=None: ( maintainer.frame_manager.get.side_effect = lambda _name, shape: np.zeros(
np.zeros(shape, np.uint8) shape, np.uint8
) )
return maintainer return maintainer
-41
View File
@@ -98,47 +98,6 @@ class TestLoadRegistry(unittest.TestCase):
self.assertIsInstance(runner, ONNXModelRunner) self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(loaded_devices["/models/arcface.onnx"], ("arcface", "CPU")) self.assertEqual(loaded_devices["/models/arcface.onnx"], ("arcface", "CPU"))
def test_only_cpu_enrichment_sessions_disable_spinning(self):
for provider, expected in [
("CPUExecutionProvider", "0"),
("CUDAExecutionProvider", None),
]:
with (
self.subTest(provider=provider),
patch.object(
detection_runners, "is_rknn_compatible", return_value=False
),
patch.object(
detection_runners,
"get_ort_providers",
return_value=([provider], [{"device_id": 0}]),
),
patch.object(
detection_runners,
"is_openvino_gpu_npu_available",
return_value=False,
),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[]
),
patch.object(detection_runners.ort, "InferenceSession") as session,
):
session.return_value.get_providers.return_value = [provider]
# paddleocr skips the CUDA graph runner, which takes no options
get_optimized_runner("/models/paddleocr.onnx", "GPU", "paddleocr")
options = session.call_args.kwargs["sess_options"]
try:
spinning = options.get_session_config_entry(
"session.intra_op.allow_spinning"
)
except RuntimeError:
spinning = None
self.assertEqual(spinning, expected)
class TestLoadedDeviceSnapshot(unittest.TestCase): class TestLoadedDeviceSnapshot(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -6,20 +6,13 @@ in-process cache (e.g. TrackedObjectProcessor) still holds a ref to
the old, smaller segment. the old, smaller segment.
""" """
import queue
import threading
import unittest import unittest
from multiprocessing import Event, Value
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import patch
import numpy as np 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.util.image import SharedMemoryFrameManager
from frigate.video.ffmpeg import capture_frames
def _fake_shm(size: int) -> SimpleNamespace: def _fake_shm(size: int) -> SimpleNamespace:
@@ -159,319 +152,5 @@ class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase):
self.assertEqual(arr_large.shape, (1620, 1920)) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
-120
View File
@@ -1,120 +0,0 @@
"""Zone entry for tracked objects the detector has lost."""
import unittest
from frigate.config import FrigateConfig
from frigate.track.tracked_object import TrackedObject
CAMERA = "cam"
FPS = 5
INERTIA = 3
OUTSIDE_X = 200
INSIDE_X = 480
def _config() -> FrigateConfig:
return FrigateConfig(
**{
"mqtt": {"enabled": False},
"cameras": {
CAMERA: {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"width": 640, "height": 480, "fps": FPS},
"objects": {"track": ["car"]},
"zones": {
"zone": {
"coordinates": "0.5,0,1,0,1,1,0.5,1",
"inertia": INERTIA,
}
},
}
},
}
)
def _obj_data(x: int, frame_time: float) -> dict:
return {
"id": "1000.0-abcdef",
"label": "car",
"frame_time": frame_time,
"start_time": 1000.0,
"score": 0.9,
"box": (x - 40, 200, x + 40, 260),
"area": 4800,
"ratio": 1.33,
"region": (0, 0, 640, 480),
"centroid": (x, 230),
"position_changes": 1,
"motionless_count": 0,
"attributes": [],
}
class TestTrackedObjectZones(unittest.TestCase):
def setUp(self):
config = _config()
camera_config = config.cameras[CAMERA]
self.obj = TrackedObject(
config.model_for_camera(CAMERA),
camera_config,
config.ui,
{},
{**_obj_data(OUTSIDE_X, 1000.0), "score_history": [0.9]},
)
self.frame = 0
def _step(self, x: int, detected: bool = True) -> None:
"""Advance one frame. A lost object keeps the box from its last detection."""
self.frame += 1
current_frame_time = 1000.0 + self.frame / FPS
if detected:
self.last_detection = _obj_data(x, current_frame_time)
self.obj.update(current_frame_time, dict(self.last_detection), False)
def test_lost_object_does_not_enter_zone(self):
self._step(INSIDE_X)
for _ in range(INERTIA * 3):
self._step(INSIDE_X, detected=False)
self.assertEqual(self.obj.current_zones, [])
self.assertEqual(self.obj.entered_zones, [])
def test_lost_object_enters_zone_when_detected_again(self):
self._step(INSIDE_X)
for _ in range(INERTIA):
self._step(INSIDE_X, detected=False)
self._step(INSIDE_X)
self.assertEqual(self.obj.entered_zones, ["zone"])
def test_detected_object_enters_zone(self):
for _ in range(INERTIA - 1):
self._step(INSIDE_X)
self.assertEqual(self.obj.entered_zones, [])
self._step(INSIDE_X)
self.assertEqual(self.obj.current_zones, ["zone"])
self.assertEqual(self.obj.entered_zones, ["zone"])
def test_object_in_zone_stays_while_lost(self):
for _ in range(INERTIA):
self._step(INSIDE_X)
for _ in range(INERTIA):
self._step(INSIDE_X, detected=False)
self.assertEqual(self.obj.current_zones, ["zone"])
if __name__ == "__main__":
unittest.main()
-20
View File
@@ -1,20 +0,0 @@
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")
-131
View File
@@ -1,131 +0,0 @@
"""Outbound websocket sends must never block the thread calling publish()."""
import socket
import threading
import unittest
from frigate.comms.ws import WS_MAX_PENDING_MESSAGES, WebSocket
class _FakeSock:
"""Socket stand-in; ``block`` makes sendall hang like a client that stopped reading."""
def __init__(self, block: bool = False) -> None:
self.block = block
self.released = threading.Event()
self.shutdown_called = threading.Event()
self.frames: list[bytes] = []
def sendall(self, data: bytes) -> None:
if self.shutdown_called.is_set():
raise BrokenPipeError()
if self.block and not self.released.is_set():
self.released.wait(timeout=10)
raise BrokenPipeError()
self.frames.append(data)
def shutdown(self, how: int) -> None:
assert how == socket.SHUT_RDWR
self.shutdown_called.set()
self.released.set()
def close(self) -> None:
pass
def setsockopt(self, *args) -> None:
pass
def fileno(self) -> int:
return 99
def _wait_for(predicate, timeout: float = 2.0) -> bool:
deadline = threading.Event()
for _ in range(int(timeout / 0.01)):
if predicate():
return True
deadline.wait(0.01)
return predicate()
class TestWebSocketSendQueue(unittest.TestCase):
def _open(self, sock: _FakeSock) -> WebSocket:
ws = WebSocket(sock)
ws.opened()
return ws
def test_stalled_client_does_not_block_publisher(self):
sock = _FakeSock(block=True)
ws = self._open(sock)
def publish_many():
for i in range(WS_MAX_PENDING_MESSAGES + 5):
ws.send(f"message {i}")
publisher = threading.Thread(target=publish_many, daemon=True)
publisher.start()
publisher.join(timeout=2.0)
self.assertFalse(publisher.is_alive(), "publish() blocked on a stalled client")
self.assertTrue(
sock.shutdown_called.wait(timeout=2.0),
"a client that cannot keep up should be disconnected",
)
ws._writer.join(2)
self.assertFalse(ws._writer.is_alive())
def test_overflow_warns_and_shuts_down_once(self):
sock = _FakeSock(block=True)
ws = self._open(sock)
shutdown_calls = []
original_shutdown = sock.shutdown
sock.shutdown = lambda how: (shutdown_calls.append(how), original_shutdown(how))
with self.assertLogs("frigate.comms.ws", level="WARNING") as logs:
# keep publishing after overflow, as the dispatcher does until the
# manager thread removes the connection
for i in range(WS_MAX_PENDING_MESSAGES * 3):
ws.send(f"message {i}")
self.assertEqual(len(logs.output), 1)
self.assertEqual(len(shutdown_calls), 1)
def test_messages_delivered_in_order(self):
sock = _FakeSock()
ws = self._open(sock)
for i in range(3):
ws.send(f"message {i}")
self.assertTrue(_wait_for(lambda: len(sock.frames) == 3))
for i, frame in enumerate(sock.frames):
self.assertIn(f"message {i}".encode(), frame)
self.assertFalse(sock.shutdown_called.is_set())
def test_closed_stops_writer_thread(self):
sock = _FakeSock()
ws = self._open(sock)
writer = ws._writer
ws.closed(1000, "bye")
writer.join(timeout=2.0)
self.assertFalse(writer.is_alive())
def test_closed_with_full_queue_and_blocked_writer_exits_after_shutdown(self):
sock = _FakeSock(block=True)
ws = self._open(sock)
ws.send("in flight")
self.assertTrue(_wait_for(ws._send_queue.empty))
for i in range(WS_MAX_PENDING_MESSAGES):
ws.send(f"message {i}")
self.assertTrue(ws._send_queue.full())
ws.closed(1006)
self.assertTrue(ws._writer.is_alive())
self.assertFalse(sock.shutdown_called.is_set())
sock.shutdown(socket.SHUT_RDWR)
ws._writer.join(2)
self.assertFalse(ws._writer.is_alive())
if __name__ == "__main__":
unittest.main()
+57 -86
View File
@@ -180,9 +180,48 @@ class NorfairTracker(ObjectTracker):
} }
self.trackers: dict[str, dict[str, Tracker]] = {} self.trackers: dict[str, dict[str, Tracker]] = {}
self.default_tracker: dict[str, Tracker] = {} # Handle static trackers
self.tracker_selection: tuple[bool | None, tuple[str, ...]] | None = None for obj_type, tracker_config in self.object_type_configs.items():
self.sync_trackers() 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]
),
}
if self.camera_config.onvif.autotracking.enabled: if self.camera_config.onvif.autotracking.enabled:
self.ptz_motion_estimator = PtzMotionEstimator( self.ptz_motion_estimator = PtzMotionEstimator(
@@ -218,91 +257,18 @@ class NorfairTracker(ObjectTracker):
return Tracker(**tracker_params) return Tracker(**tracker_params)
def _tracker_selection(self) -> tuple[bool | None, tuple[str, ...]]: def get_tracker(self, object_type: str) -> Tracker:
autotracking = self.camera_config.onvif.autotracking """Get the appropriate tracker based on object type and camera mode."""
mode = (
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" "ptz"
if self.camera_config.onvif.autotracking.enabled_in_config 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" else "static"
) )
if object_type in self.trackers:
def get_tracker(self, object_type: str) -> Tracker: return self.trackers[object_type][mode]
"""Get the tracker that match_and_update feeds this label's detections to.""" return self.default_tracker[mode]
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: def register(self, track_id: str, obj: dict[str, Any]) -> None:
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
@@ -568,7 +534,7 @@ class NorfairTracker(ObjectTracker):
points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]]) points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]])
embedding = None embedding = None
if self.camera_config.onvif.autotracking.enabled and yuv_frame is not None: if self.camera_config.onvif.autotracking.enabled:
embedding = get_histogram( embedding = get_histogram(
yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3] yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3]
) )
@@ -621,7 +587,12 @@ class NorfairTracker(ObjectTracker):
default_detections.extend(dets) default_detections.extend(dets)
# Update default tracker with untracked detections # Update default tracker with untracked detections
tracked_objects = self.default_tracker[self._default_mode()].update( mode = (
"ptz"
if self.camera_config.onvif.autotracking.enabled_in_config
else "static"
)
tracked_objects = self.default_tracker[mode].update(
detections=default_detections, coord_transformations=coord_transformations detections=default_detections, coord_transformations=coord_transformations
) )
all_tracked_objects.extend(tracked_objects) all_tracked_objects.extend(tracked_objects)
+5 -21
View File
@@ -118,9 +118,7 @@ class TrackedObjectProcessor(threading.Thread):
def create_camera_state(self, camera: str) -> None: def create_camera_state(self, camera: str) -> None:
"""Creates a new camera state.""" """Creates a new camera state."""
def start( def start(camera: str, obj: TrackedObject, frame_name: str) -> None:
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
self.event_sender.publish( self.event_sender.publish(
( (
EventTypeEnum.tracked_object, EventTypeEnum.tracked_object,
@@ -128,13 +126,10 @@ class TrackedObjectProcessor(threading.Thread):
camera, camera,
frame_name, frame_name,
obj.to_dict(), obj.to_dict(),
frame_time,
) )
) )
def update( def update(camera: str, obj: TrackedObject, frame_name: str) -> None:
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
obj.has_snapshot = self.should_save_snapshot( obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj camera_state.camera_config, obj
) )
@@ -154,18 +149,13 @@ class TrackedObjectProcessor(threading.Thread):
camera, camera,
frame_name, frame_name,
obj.to_dict(), obj.to_dict(),
frame_time,
) )
) )
def autotrack( def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None:
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
self.ptz_autotracker_thread.autotrack_object(camera, obj) self.ptz_autotracker_thread.autotrack_object(camera, obj)
def end( def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
# populate has_snapshot # populate has_snapshot
obj.has_snapshot = self.should_save_snapshot( obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj camera_state.camera_config, obj
@@ -196,7 +186,6 @@ class TrackedObjectProcessor(threading.Thread):
camera, camera,
frame_name, frame_name,
obj.to_dict(), obj.to_dict(),
frame_time,
) )
) )
@@ -610,7 +599,6 @@ class TrackedObjectProcessor(threading.Thread):
"type": source_type, "type": source_type,
"draw": draw, "draw": draw,
}, },
None,
) )
) )
@@ -676,7 +664,6 @@ class TrackedObjectProcessor(threading.Thread):
"recognized_license_plate": plate, "recognized_license_plate": plate,
"recognized_license_plate_score": score, "recognized_license_plate_score": score,
}, },
None,
) )
) )
@@ -705,7 +692,6 @@ class TrackedObjectProcessor(threading.Thread):
None, None,
"", "",
{"id": event_id, "end_time": end_time}, {"id": event_id, "end_time": end_time},
None,
) )
) )
@@ -727,14 +713,13 @@ class TrackedObjectProcessor(threading.Thread):
def force_end_all_events(self, camera: str, camera_state: CameraState) -> None: def force_end_all_events(self, camera: str, camera_state: CameraState) -> None:
"""Ends all active events on camera when disabling.""" """Ends all active events on camera when disabling."""
last_frame_name = camera_state.previous_frame_id 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()): for obj_id, obj in list(camera_state.tracked_objects.items()):
if "end_time" not in obj.obj_data: if "end_time" not in obj.obj_data:
logger.debug(f"Camera {camera} disabled, ending active event {obj_id}") logger.debug(f"Camera {camera} disabled, ending active event {obj_id}")
obj.obj_data["end_time"] = datetime.datetime.now().timestamp() obj.obj_data["end_time"] = datetime.datetime.now().timestamp()
# end callbacks # end callbacks
for callback in camera_state.callbacks["end"]: for callback in camera_state.callbacks["end"]:
callback(camera, obj, last_frame_name, last_frame_time) callback(camera, obj, last_frame_name)
# camera activity callbacks # camera activity callbacks
for callback in camera_state.callbacks["camera_activity"]: for callback in camera_state.callbacks["camera_activity"]:
@@ -763,7 +748,6 @@ class TrackedObjectProcessor(threading.Thread):
continue continue
camera_state.shutdown() camera_state.shutdown()
self.frame_manager.close_camera(camera)
with self.camera_states_lock: with self.camera_states_lock:
self.camera_states.pop(camera) self.camera_states.pop(camera)
+2 -5
View File
@@ -186,7 +186,6 @@ class TrackedObject:
# check zones # check zones
current_zones = [] current_zones = []
bottom_center = (obj_data["centroid"][0], obj_data["box"][3]) bottom_center = (obj_data["centroid"][0], obj_data["box"][3])
detected = obj_data["frame_time"] == current_frame_time
in_loitering_zone = False in_loitering_zone = False
in_speed_zone = False in_speed_zone = False
@@ -276,13 +275,11 @@ class TrackedObject:
loitering_score = self.zone_loitering.get(name, 0) + 1 loitering_score = self.zone_loitering.get(name, 0) + 1
# loitering time is configured as seconds, convert to count of frames. # loitering time is configured as seconds, convert to count of frames
# a lost object keeps counting but can't newly enter a zone
# until it is detected again
if loitering_score >= ( if loitering_score >= (
self.camera_config.zones[name].loitering_time self.camera_config.zones[name].loitering_time
* self.camera_config.detect.fps * self.camera_config.detect.fps
) and (detected or name in self.current_zones): ):
current_zones.append(name) current_zones.append(name)
if name not in self.entered_zones: if name not in self.entered_zones:
+4 -20
View File
@@ -747,14 +747,8 @@ def collect_object_classification_examples(
selected_events = _select_balanced_events(events, target_count=100) selected_events = _select_balanced_events(events, target_count=100)
logger.debug(f"Selected {len(selected_events)} events") logger.debug(f"Selected {len(selected_events)} events")
# Step 3: Extract thumbnails from events, falling back to the remaining # Step 3: Extract thumbnails from events
# events when the selected ones have no image on disk thumbnails = _extract_event_thumbnails(selected_events, temp_dir)
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") logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails")
# Step 4: Select 24 most visually distinct thumbnails # Step 4: Select 24 most visually distinct thumbnails
@@ -839,16 +833,10 @@ def _select_balanced_events(
else: else:
selected.extend(remaining) 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] return selected[:target_count]
def _extract_event_thumbnails( def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]:
events: list[Event], output_dir: str, target_count: int = 100
) -> list[str]:
""" """
Extract a training image for each event. Extract a training image for each event.
@@ -862,9 +850,8 @@ def _extract_event_thumbnails(
using a step ladder sized from the box/region area ratio. using a step ladder sized from the box/region area ratio.
Args: Args:
events: List of Event objects, in order of preference events: List of Event objects
output_dir: Directory to save crops output_dir: Directory to save crops
target_count: Number of images to extract before stopping
Returns: Returns:
List of paths to successfully extracted images List of paths to successfully extracted images
@@ -872,9 +859,6 @@ def _extract_event_thumbnails(
image_paths = [] image_paths = []
for idx, event in enumerate(events): for idx, event in enumerate(events):
if len(image_paths) >= target_count:
break
try: try:
img = _load_event_classification_crop(event) img = _load_event_classification_crop(event)
if img is None: if img is None:
+5 -105
View File
@@ -990,23 +990,13 @@ class FrameManager(ABC):
pass pass
@abstractmethod @abstractmethod
def get( def get(self, name: str, timeout_ms: int = 0):
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 pass
@abstractmethod @abstractmethod
def close(self, name: str): def close(self, name: str):
pass pass
@abstractmethod
def close_camera(self, camera: str) -> None:
pass
@abstractmethod @abstractmethod
def delete(self, name: str): def delete(self, name: str):
pass pass
@@ -1089,56 +1079,7 @@ class SharedMemoryFrameManager(FrameManager):
logger.info(f"the file {name} not found") logger.info(f"the file {name} not found")
return None return None
def frame_times(self, camera: str) -> np.ndarray | None: def get(self, name: str, shape) -> 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: try:
required = int(np.prod(shape)) required = int(np.prod(shape))
shm = self.shm_store.get(name) shm = self.shm_store.get(name)
@@ -1169,14 +1110,6 @@ class SharedMemoryFrameManager(FrameManager):
self.shm_store[name].close() self.shm_store[name].close()
del self.shm_store[name] 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): def delete(self, name: str):
if name in self.shm_store: if name in self.shm_store:
self.shm_store[name].close() self.shm_store[name].close()
@@ -1308,42 +1241,9 @@ def get_image_from_recording(
return image_data return image_data
def get_histogram( def get_histogram(image, x_min, y_min, x_max, y_max):
image: np.ndarray, x_min: int, y_min: int, x_max: int, y_max: int image_bgr = cv2.cvtColor(image, cv2.COLOR_YUV2BGR_I420)
) -> np.ndarray: image_bgr = image_bgr[y_min:y_max, x_min:x_max]
"""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( hist = cv2.calcHist(
[image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256] [image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256]
+3 -4
View File
@@ -204,12 +204,11 @@ def coverage_spans(intervals: list[CoverageInterval]) -> list[dict[str, Any]]:
def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool: def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
"""Whether a stream is audio-bearing over a coverage window. """Whether a stream is audio-bearing over a coverage window.
A stream counts as audio-bearing only when one of its rows is known A stream counts as audio-bearing unless EVERY one of its rows reports
to carry audio. A NULL row (legacy, or a segment ffprobe could not has_audio False; NULL (legacy or undetermined) counts as audio.
read) proves nothing either way.
""" """
return any( return any(
row is not None and row.has_audio is True row is not None and row.has_audio is not False
for row in ((interval.main if main else interval.sub) for interval in intervals) for row in ((interval.main if main else interval.sub) for interval in intervals)
) )
+6 -21
View File
@@ -3,32 +3,17 @@
import datetime import datetime
import logging import logging
import math import math
from typing import Final from zoneinfo import ZoneInfoNotFoundError
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
import pytz
from tzlocal import get_localzone from tzlocal import get_localzone
logger = logging.getLogger(__name__) 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]: def get_tz_modifiers(tz_name: str) -> tuple[str, str, float]:
seconds_offset = ( seconds_offset = (
datetime.datetime.now(ZoneInfo(get_normalized_tz_name(tz_name))) datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()
.utcoffset()
.total_seconds()
) )
hours_offset = int(seconds_offset / 60 / 60) hours_offset = int(seconds_offset / 60 / 60)
minutes_offset = int(seconds_offset / 60 - hours_offset * 60) minutes_offset = int(seconds_offset / 60 - hours_offset * 60)
@@ -41,7 +26,7 @@ def get_tomorrow_at_time(hour: int) -> datetime.datetime:
"""Returns the datetime of the following day at 2am.""" """Returns the datetime of the following day at 2am."""
try: try:
tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1) tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1)
except (ValueError, ZoneInfoNotFoundError): except ZoneInfoNotFoundError:
tomorrow = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1) tomorrow = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
logger.warning( logger.warning(
"Using utc for maintenance due to missing or incorrect timezone set" "Using utc for maintenance due to missing or incorrect timezone set"
@@ -99,8 +84,8 @@ def get_dst_transitions(
continuous periods with the same UTC offset continuous periods with the same UTC offset
""" """
try: try:
tz = ZoneInfo(get_normalized_tz_name(tz_name)) tz = pytz.timezone(tz_name)
except (ValueError, ZoneInfoNotFoundError): except pytz.UnknownTimeZoneError:
# If timezone is invalid, return single period with no offset # If timezone is invalid, return single period with no offset
return [(start_time, end_time, 0)] return [(start_time, end_time, 0)]
+2 -6
View File
@@ -144,11 +144,6 @@ class CameraTracker(FrigateProcess):
def run(self) -> None: def run(self) -> None:
self.pre_run_setup(self.log_config) 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_queue = self.camera_metrics.frame_queue
frame_shape = self.config.frame_shape frame_shape = self.config.frame_shape
@@ -307,7 +302,6 @@ def process_frames(
motion_detector.autotracking_enabled = ( motion_detector.autotracking_enabled = (
camera_config.onvif.autotracking.enabled camera_config.onvif.autotracking.enabled
) )
object_tracker.sync_trackers()
if ( if (
not camera_enabled not camera_enabled
@@ -607,6 +601,7 @@ def process_frames(
) )
# add to the queue if not full # add to the queue if not full
if detected_objects_queue.full(): if detected_objects_queue.full():
frame_manager.close(frame_name)
dropped_frames.dropped(time.monotonic()) dropped_frames.dropped(time.monotonic())
continue continue
else: else:
@@ -623,6 +618,7 @@ def process_frames(
) )
) )
camera_metrics.detection_fps.value = object_detector.fps.eps() camera_metrics.detection_fps.value = object_detector.fps.eps()
frame_manager.close(frame_name)
motion_detector.stop() motion_detector.stop()
requestor.stop() requestor.stop()
+3 -11
View File
@@ -64,7 +64,6 @@ def capture_frames(
frame_rate.start() frame_rate.start()
skipped_eps = EventsPerSecond() skipped_eps = EventsPerSecond()
skipped_eps.start() skipped_eps.start()
frame_times = frame_manager.frame_times(config.name)
while not stop_event.is_set(): while not stop_event.is_set():
# CameraWatchdog applies enabled updates onto this same CameraConfig # CameraWatchdog applies enabled updates onto this same CameraConfig
@@ -79,14 +78,8 @@ def capture_frames(
current_frame.value = datetime.now().timestamp() current_frame.value = datetime.now().timestamp()
frame_name = f"{config.name}_frame{frame_index}" frame_name = f"{config.name}_frame{frame_index}"
frame_buffer = frame_manager.write(frame_name) 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: try:
if ffmpeg_process.stdout.readinto(frame_buffer) != frame_size: frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
raise EOFError("incomplete frame")
except Exception: except Exception:
# shutdown has been initiated # shutdown has been initiated
if stop_event.is_set(): if stop_event.is_set():
@@ -102,17 +95,16 @@ def capture_frames(
continue continue
frame_times[frame_index] = current_frame.value
frame_rate.update() frame_rate.update()
# don't lock the queue to check, just try since it should rarely be full # don't lock the queue to check, just try since it should rarely be full
try: try:
# add to the queue # add to the queue
frame_queue.put((frame_name, current_frame.value), False) frame_queue.put((frame_name, current_frame.value), False)
frame_manager.close(frame_name)
except queue.Full: except queue.Full:
# nobody was told about this slot, so the next frame reuses it # if the queue is full, skip this frame
skipped_eps.update() skipped_eps.update()
continue
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1 frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
+2 -70
View File
@@ -6,9 +6,9 @@
* viewer nav restrictions; all-routes smoke. * viewer nav restrictions; all-routes smoke.
*/ */
import { test, expect, type FrigateApp } from "../fixtures/frigate-test"; import { test, expect } from "../fixtures/frigate-test";
import { systemTab } from "../helpers/system-tabs"; import { systemTab } from "../helpers/system-tabs";
import { adminProfile, viewerProfile } from "../fixtures/mock-data/profile"; import { viewerProfile } from "../fixtures/mock-data/profile";
test.describe("Auth — admin access @high", () => { test.describe("Auth — admin access @high", () => {
test("admin /system renders general tab", async ({ frigateApp }) => { test("admin /system renders general tab", async ({ frigateApp }) => {
@@ -109,71 +109,3 @@ 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,27 +66,6 @@ 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 = [ const HAILO_HARDWARE = [
{ {
key: "hailo", key: "hailo",
@@ -105,7 +84,7 @@ async function installRoutes(
models: Model[], models: Model[],
plusEnabled = false, plusEnabled = false,
plusModels: unknown[] = [PLUS_MODEL], plusModels: unknown[] = [PLUS_MODEL],
hardware?: unknown[], hailoHardware = false,
) { ) {
const config = configFactory({ const config = configFactory({
models, models,
@@ -128,9 +107,9 @@ async function installRoutes(
route.fulfill({ json: plusModels }), route.fulfill({ json: plusModels }),
); );
if (hardware) { if (hailoHardware) {
await page.route("**/api/hardware/probe**", (route) => await page.route("**/api/hardware/probe**", (route) =>
route.fulfill({ json: hardware }), route.fulfill({ json: HAILO_HARDWARE }),
); );
} }
await page.route("**/api/config/set", async (route) => { await page.route("**/api/config/set", async (route) => {
@@ -223,39 +202,6 @@ test.describe("Detection models settings @high", () => {
).not.toBeChecked(); ).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 ({ test("a unit claimed by another model cannot be picked", async ({
frigateApp, frigateApp,
}) => { }) => {
@@ -564,7 +510,7 @@ test.describe("Detection models settings @high", () => {
], ],
true, true,
HAILO_PLUS_MODELS, HAILO_PLUS_MODELS,
HAILO_HARDWARE, true,
); );
await openPage(frigateApp); await openPage(frigateApp);
+2 -46
View File
@@ -14,17 +14,6 @@ const NOW = Math.floor(Date.now() / 1000);
// the fixture detector runs at 75.5 ms, above the live warning threshold // the fixture detector runs at 75.5 ms, above the live warning threshold
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } }; 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 = { const ERROR_NOTICE = {
id: "model_download_failed:yolo/model.onnx", id: "model_download_failed:yolo/model.onnx",
kind: "model_download_failed", kind: "model_download_failed",
@@ -62,7 +51,6 @@ test.describe("System — Health tab @medium", () => {
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE], notices: [ERROR_NOTICE, EVENT_NOTICE],
}); });
@@ -96,7 +84,6 @@ test.describe("System — Health tab @medium", () => {
for (const action of ["acknowledge", "mute"] as const) { for (const action of ["acknowledge", "mute"] as const) {
test(`${action} posts and removes the row`, async ({ frigateApp }) => { test(`${action} posts and removes the row`, async ({ frigateApp }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [EVENT_NOTICE], notices: [EVENT_NOTICE],
}); });
@@ -139,10 +126,7 @@ test.describe("System — Health tab @medium", () => {
} }
test("empty state with no notices", async ({ frigateApp }) => { test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({ stats: QUIET_STATS });
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await expect( await expect(
@@ -345,7 +329,6 @@ test.describe("System — Health tab @medium", () => {
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE], notices: [ERROR_NOTICE, EVENT_NOTICE],
}); });
@@ -782,7 +765,6 @@ test.describe("System — Health notices sources @medium", () => {
test("status bar problems stay out of the list", async ({ frigateApp }) => { test("status bar problems stay out of the list", async ({ frigateApp }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only"); test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: { stats: {
service: { retention_unmet: true }, service: { retention_unmet: true },
cameras: { front_door: { camera_fps: 0 } }, cameras: { front_door: { camera_fps: 0 } },
@@ -847,28 +829,6 @@ test.describe("System — Health notices sources @medium", () => {
).toBeVisible({ timeout: 15_000 }); ).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 ({ test("a global config problem is not repeated per camera", async ({
frigateApp, frigateApp,
}) => { }) => {
@@ -933,10 +893,7 @@ test.describe("System — Health notices sources @medium", () => {
test("empty state when stats, config, and registry are clean", async ({ test("empty state when stats, config, and registry are clean", async ({
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({ stats: QUIET_STATS });
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await expect( await expect(
@@ -1173,7 +1130,6 @@ test.describe("System — Health notices sources @medium", () => {
}) => { }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only"); test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [EVENT_NOTICE], notices: [EVENT_NOTICE],
}); });
@@ -1997,9 +1997,6 @@
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.", "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." "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": { "audio": {
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function." "noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
}, },
+2 -2
View File
@@ -35,8 +35,8 @@
"showAll": "Show all again", "showAll": "Show all again",
"showAllTitle": "Show all hidden notices?", "showAllTitle": "Show all hidden notices?",
"showAllDesc": "Every acknowledged and muted notice returns to the Notices list, including config and stream checks.", "showAllDesc": "Every acknowledged and muted notice returns to the Notices list, including config and stream checks.",
"occurred_one": "Occurred {{time}}", "firstSeen_one": "First seen {{time}}",
"occurred_other": "Last occurred {{lastTime}} · {{count}} times since {{time}}", "firstSeen_other": "First seen {{time}} · {{count}} times",
"acknowledgedAt": "Acknowledged {{time}}", "acknowledgedAt": "Acknowledged {{time}}",
"mutedAt": "Muted {{time}}", "mutedAt": "Muted {{time}}",
"filter": { "filter": {
-45
View File
@@ -1,5 +1,3 @@
import { baseUrl } from "./baseUrl";
// Module-level flag to prevent multiple simultaneous redirects // Module-level flag to prevent multiple simultaneous redirects
// (eg, when multiple SWR queries fail with 401 at once, or when // (eg, when multiple SWR queries fail with 401 at once, or when
// both ApiProvider and ProtectedRoute try to redirect) // both ApiProvider and ProtectedRoute try to redirect)
@@ -12,46 +10,3 @@ export function isRedirectingToLogin(): boolean {
export function setRedirectingToLogin(value: boolean): void { export function setRedirectingToLogin(value: boolean): void {
_isRedirectingToLogin = value; _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;
}
}
+2 -6
View File
@@ -3,11 +3,7 @@ import { SWRConfig } from "swr";
import { WsProvider } from "./WsProvider"; import { WsProvider } from "./WsProvider";
import axios from "axios"; import axios from "axios";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect";
getLoginUrl,
isRedirectingToLogin,
setRedirectingToLogin,
} from "./auth-redirect";
axios.defaults.baseURL = `${baseUrl}api/`; axios.defaults.baseURL = `${baseUrl}api/`;
@@ -38,7 +34,7 @@ export function ApiProvider({ children, options }: ApiProviderType) {
const loginPage = error.response.headers.get("location") ?? "login"; const loginPage = error.response.headers.get("location") ?? "login";
if (window.location.href !== loginPage && !isRedirectingToLogin()) { if (window.location.href !== loginPage && !isRedirectingToLogin()) {
setRedirectingToLogin(true); setRedirectingToLogin(true);
window.location.href = getLoginUrl(loginPage); window.location.href = loginPage;
} }
} }
}, },
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react"; import * as React from "react";
import { getPostLoginUrl } from "@/api/auth-redirect"; import { baseUrl } from "../../api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -69,7 +69,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
username: profileRes.data.username, username: profileRes.data.username,
role: profileRes.data.role || "viewer", role: profileRes.data.role || "viewer",
}); });
window.location.href = getPostLoginUrl(); window.location.href = baseUrl;
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const err = error as AxiosError; 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 { AuthContext } from "@/context/auth-context";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { import {
getLoginUrl,
isRedirectingToLogin, isRedirectingToLogin,
setRedirectingToLogin, setRedirectingToLogin,
} from "@/api/auth-redirect"; } from "@/api/auth-redirect";
import { baseUrl } from "@/api/baseUrl";
export default function ProtectedRoute({ export default function ProtectedRoute({
requiredRoles, requiredRoles,
@@ -25,7 +25,7 @@ export default function ProtectedRoute({
!isRedirectingToLogin() !isRedirectingToLogin()
) { ) {
setRedirectingToLogin(true); setRedirectingToLogin(true);
window.location.href = getLoginUrl(); window.location.href = `${baseUrl}login`;
} }
}, [auth.isLoading, auth.isAuthenticated, auth.user]); }, [auth.isLoading, auth.isAuthenticated, auth.user]);
@@ -1,24 +1,8 @@
import { isRestreamedStream } from "@/utils/liveTranscode";
import type { SectionConfigOverrides } from "./types"; import type { SectionConfigOverrides } from "./types";
const live: SectionConfigOverrides = { const live: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/live", 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: [], restartRequired: [],
fieldOrder: ["streams", "transcode", "height", "quality"], fieldOrder: ["streams", "transcode", "height", "quality"],
fieldGroups: {}, fieldGroups: {},
@@ -15,7 +15,6 @@ import {
hardwareForDevices, hardwareForDevices,
MAX_DETECTORS, MAX_DETECTORS,
recommendedDetectorCount, recommendedDetectorCount,
resolveUnitDevice,
} from "@/utils/detectionHardware"; } from "@/utils/detectionHardware";
type HardwarePickerProps = { type HardwarePickerProps = {
@@ -61,13 +60,9 @@ export function HardwarePicker({
return []; return [];
} }
const assigned = devices.map((device) =>
resolveUnitDevice(selected, device),
);
return selected.units return selected.units
.map((unit) => unit.device) .map((unit) => unit.device)
.filter((device) => assigned.includes(device)); .filter((device) => devices.includes(device));
}, [selected, devices]); }, [selected, devices]);
/** Spread `count` detectors round robin over the selected units. */ /** Spread `count` detectors round robin over the selected units. */
@@ -205,7 +200,7 @@ export function HardwarePicker({
<Checkbox <Checkbox
id={`${idPrefix}-${unit.device}`} id={`${idPrefix}-${unit.device}`}
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white" className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={selectedUnits.includes(unit.device)} checked={devices.includes(unit.device)}
disabled={disabled || Boolean(claimedBy)} disabled={disabled || Boolean(claimedBy)}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleUnitToggle(unit.device, checked === true) handleUnitToggle(unit.device, checked === true)
@@ -55,10 +55,6 @@ export default function DetailActionsMenu({
// currently, audio event ids are not saved in review items // currently, audio event ids are not saved in review items
const { data: reviewItem } = useSWR<ReviewSegment>( const { data: reviewItem } = useSWR<ReviewSegment>(
search.data?.type === "audio" ? null : [`review/event/${search.id}`], search.data?.type === "audio" ? null : [`review/event/${search.id}`],
{
shouldRetryOnError: (err) =>
!(axios.isAxiosError(err) && err.response?.status === 404),
},
); );
const handleDebugReplay = useCallback(() => { const handleDebugReplay = useCallback(() => {
+1 -2
View File
@@ -118,10 +118,9 @@ export function useHealthProblems(
ns: "views/system", ns: "views/system",
time: formatTime(notice.acknowledged_at), time: formatTime(notice.acknowledged_at),
}) })
: t("health.notices.occurred", { : t("health.notices.firstSeen", {
ns: "views/system", ns: "views/system",
time: formatTime(notice.first_seen), time: formatTime(notice.first_seen),
lastTime: formatTime(notice.last_seen),
count: notice.count, count: notice.count,
}), }),
link: external ? undefined : link, link: external ? undefined : link,
+4 -25
View File
@@ -21,31 +21,10 @@ export function hardwareForDevices(
return undefined; return undefined;
} }
return hardware.find((entry) => return hardware.find((entry) => {
devices.every((device) => resolveUnitDevice(entry, device)), const known = new Set(entry.units.map((unit) => unit.device));
); return devices.every((device) => known.has(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}.`),
)
);
} }
/** /**