mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-14 18:52:47 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ef7c22777 |
@@ -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 @@
|
|||||||
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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Vendored
+7
-7
@@ -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':
|
||||||
|
|||||||
@@ -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,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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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']}"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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
@@ -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])
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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),
|
|
||||||
)
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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")
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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)]
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}.`),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user