Compare commits

..
Author SHA1 Message Date
Josh HawkinsandGitHub 5f6043aa92 Fix enabled flag for custom classification models (#23681)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* honor enabled flag for custom classification models

for both startup and dynamically, even though the UI doesn't currently have a way to toggle dynamically

* add test
2026-07-12 03:48:14 -08:00
18 changed files with 212 additions and 133 deletions
+1
View File
@@ -25,6 +25,7 @@ peewee_migrate == 1.14.*
psutil == 7.1.* psutil == 7.1.*
pydantic == 2.10.* pydantic == 2.10.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml git+https://github.com/fbcotter/py3nvml#egg=py3nvml
pytz == 2025.*
pyzmq == 26.2.* pyzmq == 26.2.*
ruamel.yaml == 0.18.* ruamel.yaml == 0.18.*
tzlocal == 5.2 tzlocal == 5.2
+7 -7
View File
@@ -2013,7 +2013,7 @@ paths:
required: false required: false
schema: schema:
type: string type: string
default: UTC default: utc
title: Timezone title: Timezone
responses: responses:
'200': '200':
@@ -3055,7 +3055,7 @@ paths:
anyOf: anyOf:
- type: string - type: string
- type: 'null' - type: 'null'
default: UTC default: utc
title: Timezone title: Timezone
responses: responses:
'200': '200':
@@ -4153,7 +4153,7 @@ paths:
anyOf: anyOf:
- type: string - type: string
- type: 'null' - type: 'null'
default: UTC default: utc
title: Timezone title: Timezone
responses: responses:
'200': '200':
@@ -4405,7 +4405,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
@@ -4485,7 +4485,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
@@ -6780,7 +6780,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
@@ -6830,7 +6830,7 @@ paths:
required: false required: false
schema: schema:
type: string type: string
default: UTC default: utc
title: Timezone title: Timezone
responses: responses:
'200': '200':
@@ -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"
@@ -39,7 +39,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):
@@ -66,7 +66,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
@@ -76,6 +76,6 @@ class EventsSearchQueryParams(BaseModel):
class EventsSummaryQueryParams(BaseModel): class EventsSummaryQueryParams(BaseModel):
timezone: str | None = "UTC" timezone: str | None = "utc"
has_clip: int | None = None has_clip: int | None = None
has_snapshot: int | None = None has_snapshot: int | None = None
@@ -3,7 +3,7 @@ from pydantic.json_schema import SkipJsonSchema
class MediaRecordingsSummaryQueryParams(BaseModel): class MediaRecordingsSummaryQueryParams(BaseModel):
timezone: str = "UTC" timezone: str = "utc"
cameras: str | None = "all" cameras: str | None = "all"
@@ -19,7 +19,7 @@ class ReviewSummaryQueryParams(BaseModel):
cameras: str = "all" cameras: str = "all"
labels: str = "all" labels: str = "all"
zones: str = "all" zones: str = "all"
timezone: str = "UTC" timezone: str = "utc"
class ReviewActivityMotionQueryParams(BaseModel): class ReviewActivityMotionQueryParams(BaseModel):
+9 -12
View File
@@ -1,7 +1,6 @@
import asyncio import asyncio
import logging import logging
import re import re
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -76,20 +75,9 @@ def create_fastapi_app(
profile_manager: ProfileManager | None = None, profile_manager: ProfileManager | None = None,
enforce_default_admin: bool = True, enforce_default_admin: bool = True,
): ):
@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
@@ -125,6 +113,15 @@ def create_fastapi_app(
database.close() database.close()
return response return response
@app.on_event("startup")
async def startup():
logger.info("FastAPI started")
asyncio.create_task(
debug_replay_auto_stop_watchdog(
replay_manager, frigate_config, config_publisher
)
)
# Rate limiter (used for login endpoint) # Rate limiter (used for login endpoint)
if frigate_config.auth.failed_login_rate_limit is None: if frigate_config.auth.failed_login_rate_limit is None:
limiter.enabled = False limiter.enabled = False
+2 -5
View File
@@ -11,10 +11,10 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path as FilePath from pathlib import Path as FilePath
from typing import Any from typing import 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
@@ -53,7 +53,6 @@ from frigate.util.file import (
) )
from frigate.util.image import get_image_from_recording, get_image_quality_params from frigate.util.image import get_image_from_recording, get_image_quality_params
from frigate.util.media import get_keyframe_before from frigate.util.media import get_keyframe_before
from frigate.util.time import get_normalized_tz_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -714,9 +713,7 @@ async def vod_hour(
parts = year_month.split("-") parts = year_month.split("-")
start_date = ( start_date = (
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC) datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC)
- datetime.now( - datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
).utcoffset()
) )
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp() start_ts = start_date.timestamp()
+2 -5
View File
@@ -5,8 +5,8 @@ import logging
import os import os
import threading import threading
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
import pytz
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -22,7 +22,6 @@ from frigate.api.defs.response.preview_response import (
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
from frigate.models import Previews from frigate.models import Previews
from frigate.util.time import get_normalized_tz_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -127,9 +126,7 @@ def preview_hour(
parts = year_month.split("-") parts = year_month.split("-")
start_date = ( start_date = (
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC) datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC)
- datetime.now( - datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
).utcoffset()
) )
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp() start_ts = start_date.timestamp()
+1 -1
View File
@@ -120,7 +120,7 @@ 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 = (
+35 -18
View File
@@ -200,6 +200,9 @@ class EmbeddingMaintainer(threading.Thread):
) )
for model_config in self.config.classification.custom.values(): for model_config in self.config.classification.custom.values():
if not model_config.enabled:
continue
self.realtime_processors.append( self.realtime_processors.append(
CustomStateClassificationProcessor( CustomStateClassificationProcessor(
self.config, model_config, self.requestor, self.metrics self.config, model_config, self.requestor, self.metrics
@@ -332,6 +335,25 @@ class EmbeddingMaintainer(threading.Thread):
for processor in self.post_processors: for processor in self.post_processors:
processor.update_config(topic, payload) processor.update_config(topic, payload)
def _remove_custom_classification_processor(self, model_name: str) -> None:
"""Shut down and drop any running processor for a custom model."""
remaining = []
for processor in self.realtime_processors:
if (
isinstance(
processor,
(
CustomStateClassificationProcessor,
CustomObjectClassificationProcessor,
),
)
and processor.model_config.name == model_name
):
processor.shutdown()
else:
remaining.append(processor)
self.realtime_processors = remaining
def _handle_custom_classification_update( def _handle_custom_classification_update(
self, topic: str, model_config: Any self, topic: str, model_config: Any
) -> None: ) -> None:
@@ -339,23 +361,7 @@ class EmbeddingMaintainer(threading.Thread):
model_name = topic.split("/")[-1] model_name = topic.split("/")[-1]
if model_config is None: if model_config is None:
remaining = [] self._remove_custom_classification_processor(model_name)
for processor in self.realtime_processors:
if (
isinstance(
processor,
(
CustomStateClassificationProcessor,
CustomObjectClassificationProcessor,
),
)
and processor.model_config.name == model_name
):
processor.shutdown()
else:
remaining.append(processor)
self.realtime_processors = remaining
logger.info( logger.info(
f"Successfully removed classification processor for model: {model_name}" f"Successfully removed classification processor for model: {model_name}"
) )
@@ -363,6 +369,13 @@ class EmbeddingMaintainer(threading.Thread):
self.config.classification.custom[model_name] = model_config self.config.classification.custom[model_name] = model_config
# A disabled model must not run; tear down any existing processor and
# do not register a new one.
if not model_config.enabled:
self._remove_custom_classification_processor(model_name)
logger.info(f"Disabled classification processor for model: {model_name}")
return
# Check if processor already exists # Check if processor already exists
for processor in self.realtime_processors: for processor in self.realtime_processors:
if isinstance( if isinstance(
@@ -702,7 +715,11 @@ class EmbeddingMaintainer(threading.Thread):
and "license_plate" not in camera_config.objects.track and "license_plate" not in camera_config.objects.track
) )
if not dedicated_lpr_enabled and len(self.config.classification.custom) == 0: has_enabled_custom = any(
c.enabled for c in self.config.classification.custom.values()
)
if not dedicated_lpr_enabled and not has_enabled_custom:
# no active features that use this data # no active features that use this data
return return
+6 -6
View File
@@ -12,8 +12,8 @@ import threading
from collections.abc import Callable from collections.abc import Callable
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytz # type: ignore[import-untyped]
from peewee import DoesNotExist from peewee import DoesNotExist
from frigate.config import FfmpegConfig, FrigateConfig from frigate.config import FfmpegConfig, FrigateConfig
@@ -31,7 +31,7 @@ from frigate.ffmpeg_presets import (
) )
from frigate.models import Export, Previews, Recordings, ReviewSegment from frigate.models import Export, Previews, Recordings, ReviewSegment
from frigate.util.ffmpeg import run_ffmpeg_with_progress from frigate.util.ffmpeg import run_ffmpeg_with_progress
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__)
@@ -371,8 +371,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(
@@ -533,8 +533,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
+21 -18
View File
@@ -1,8 +1,8 @@
"""Unit tests for recordings/media API endpoints.""" """Unit tests for recordings/media API endpoints."""
from datetime import UTC, datetime from datetime import UTC, datetime
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
@@ -51,16 +51,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
@@ -124,16 +124,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
@@ -194,13 +197,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
@@ -263,15 +266,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(
@@ -331,7 +334,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
@@ -362,8 +365,8 @@ class TestHttpMedia(BaseTestHttp):
""" """
Test recordings summary filtered to a single camera. Test recordings summary filtered to a single camera.
""" """
tz = ZoneInfo("America/New_York") tz = pytz.timezone("America/New_York")
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp() march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Insert recordings for both cameras # Insert recordings for both cameras
+1 -1
View File
@@ -250,7 +250,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
+106
View File
@@ -0,0 +1,106 @@
"""Tests that disabled custom classification models are not registered or run."""
import sys
import unittest
from unittest.mock import MagicMock
# Mock TFLite before importing the maintainer / classification modules
_MOCK_MODULES = [
"tflite_runtime",
"tflite_runtime.interpreter",
"ai_edge_litert",
"ai_edge_litert.interpreter",
]
for mod in _MOCK_MODULES:
if mod not in sys.modules:
sys.modules[mod] = MagicMock()
from frigate.data_processing.real_time.custom_classification import ( # noqa: E402
CustomObjectClassificationProcessor,
)
from frigate.embeddings.maintainer import EmbeddingMaintainer # noqa: E402
class TestCustomClassificationEnabledGating(unittest.TestCase):
"""A model with enabled: false must not keep a processor registered."""
def _make_maintainer(self) -> EmbeddingMaintainer:
# Bypass the heavy __init__; only the attributes touched by the
# config update path are needed for these tests.
maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer)
maintainer.realtime_processors = []
maintainer.config = MagicMock()
maintainer.config.classification.custom = {}
maintainer.requestor = MagicMock()
maintainer.metrics = MagicMock()
maintainer.event_metadata_publisher = MagicMock()
return maintainer
def _make_model_config(self, name: str, enabled: bool) -> MagicMock:
model_config = MagicMock()
model_config.name = name
model_config.enabled = enabled
model_config.state_config = None
return model_config
def _make_processor(self, name: str) -> MagicMock:
processor = MagicMock(spec=CustomObjectClassificationProcessor)
processor.model_config = MagicMock()
processor.model_config.name = name
return processor
def test_disabled_update_tears_down_existing_processor(self):
"""Toggling a running model to disabled shuts down and drops its processor."""
maintainer = self._make_maintainer()
processor = self._make_processor("atli")
maintainer.realtime_processors = [processor]
maintainer._handle_custom_classification_update(
"config/classification/custom/atli",
self._make_model_config("atli", enabled=False),
)
processor.shutdown.assert_called_once()
self.assertEqual(maintainer.realtime_processors, [])
def test_disabled_update_does_not_register_processor(self):
"""A disabled model that has no processor is never registered."""
maintainer = self._make_maintainer()
maintainer._handle_custom_classification_update(
"config/classification/custom/atli",
self._make_model_config("atli", enabled=False),
)
self.assertEqual(maintainer.realtime_processors, [])
def test_disabled_update_leaves_other_processors_untouched(self):
"""Disabling one model must not affect other running processors."""
maintainer = self._make_maintainer()
other = self._make_processor("simbi")
maintainer.realtime_processors = [other]
maintainer._handle_custom_classification_update(
"config/classification/custom/atli",
self._make_model_config("atli", enabled=False),
)
other.shutdown.assert_not_called()
self.assertEqual(maintainer.realtime_processors, [other])
def test_removed_model_tears_down_processor(self):
"""A None payload (model deleted) still shuts down its processor."""
maintainer = self._make_maintainer()
processor = self._make_processor("atli")
maintainer.realtime_processors = [processor]
maintainer._handle_custom_classification_update(
"config/classification/custom/atli", None
)
processor.shutdown.assert_called_once()
self.assertEqual(maintainer.realtime_processors, [])
if __name__ == "__main__":
unittest.main()
+6 -12
View File
@@ -2,7 +2,7 @@ import unittest
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import numpy as np import numpy as np
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
@@ -19,8 +19,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(
@@ -44,9 +44,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
@@ -69,9 +67,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(
@@ -120,9 +116,7 @@ class TestLocalObjectDetector(unittest.TestCase):
"label-5", "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,
-19
View File
@@ -1,19 +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"),
]
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")
+9 -23
View File
@@ -2,31 +2,17 @@
import datetime import datetime
import logging import logging
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:
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)
@@ -39,7 +25,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"
@@ -59,7 +45,7 @@ def is_current_hour(timestamp: int) -> bool:
def get_dst_transitions( def get_dst_transitions(
tz_name: str, start_time: float, end_time: float tz_name: str, start_time: float, end_time: float
) -> list[tuple[float, float, int]]: ) -> list[tuple[float, float]]:
""" """
Find DST transition points and return time periods with consistent offsets. Find DST transition points and return time periods with consistent offsets.
@@ -73,8 +59,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)]
@@ -82,14 +68,14 @@ def get_dst_transitions(
current = start_time current = start_time
# Get initial offset # Get initial offset
dt = datetime.datetime.fromtimestamp(current, tz=datetime.UTC) dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
local_dt = dt.astimezone(tz) local_dt = dt.astimezone(tz)
prev_offset = local_dt.utcoffset().total_seconds() prev_offset = local_dt.utcoffset().total_seconds()
period_start = start_time period_start = start_time
# Check each day for offset changes # Check each day for offset changes
while current <= end_time: while current <= end_time:
dt = datetime.datetime.fromtimestamp(current, tz=datetime.UTC) dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
local_dt = dt.astimezone(tz) local_dt = dt.astimezone(tz)
current_offset = local_dt.utcoffset().total_seconds() current_offset = local_dt.utcoffset().total_seconds()