mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-16 19:52:49 +03:00
Compare commits
7
Commits
7593d97e9a
...
0abc4acfa7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0abc4acfa7 | ||
|
|
da4037eb52 | ||
|
|
5c38f6ef96 | ||
|
|
38cabaa6aa | ||
|
|
f7528937e0 | ||
|
|
f4e0781ea5 | ||
|
|
a9e6f3b2e3 |
@@ -25,7 +25,6 @@ peewee_migrate == 1.14.*
|
||||
psutil == 7.1.*
|
||||
pydantic == 2.10.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
pytz == 2025.*
|
||||
pyzmq == 26.2.*
|
||||
ruamel.yaml == 0.18.*
|
||||
tzlocal == 5.2
|
||||
|
||||
@@ -72,10 +72,10 @@ Variables prefixed with `FRIGATE_` can be referenced in config fields that suppo
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Environment variables" /> to add or edit environment variables.
|
||||
|
||||
| Field | Description |
|
||||
| --------- | --------------------------------------------------------- |
|
||||
| **Key** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) |
|
||||
| **Value** | The value for the variable |
|
||||
| Field | Description |
|
||||
| ----------------- | --------------------------------------------------------- |
|
||||
| **Variable name** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) |
|
||||
| **Value** | The value for the variable |
|
||||
|
||||
Variables defined here can be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax.
|
||||
|
||||
|
||||
Vendored
+7
-7
@@ -2013,7 +2013,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -3055,7 +3055,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -4153,7 +4153,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -4405,7 +4405,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
- name: min_score
|
||||
in: query
|
||||
@@ -4485,7 +4485,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
- name: has_clip
|
||||
in: query
|
||||
@@ -6780,7 +6780,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
- name: cameras
|
||||
in: query
|
||||
@@ -6830,7 +6830,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: utc
|
||||
default: UTC
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
|
||||
@@ -7,4 +7,4 @@ class AppTimelineHourlyQueryParameters(BaseModel):
|
||||
after: float | None = None
|
||||
before: float | None = None
|
||||
limit: int | None = 200
|
||||
timezone: str | None = "utc"
|
||||
timezone: str | None = "UTC"
|
||||
|
||||
@@ -39,7 +39,7 @@ class EventsQueryParams(BaseModel):
|
||||
max_length: float | None = None
|
||||
event_id: str | None = None
|
||||
sort: str | None = None
|
||||
timezone: str | None = "utc"
|
||||
timezone: str | None = "UTC"
|
||||
|
||||
|
||||
class EventsSearchQueryParams(BaseModel):
|
||||
@@ -66,7 +66,7 @@ class EventsSearchQueryParams(BaseModel):
|
||||
has_clip: bool | None = None
|
||||
has_snapshot: bool | None = None
|
||||
is_submitted: bool | None = None
|
||||
timezone: str | None = "utc"
|
||||
timezone: str | None = "UTC"
|
||||
min_score: float | None = None
|
||||
max_score: float | None = None
|
||||
min_speed: float | None = None
|
||||
@@ -76,6 +76,6 @@ class EventsSearchQueryParams(BaseModel):
|
||||
|
||||
|
||||
class EventsSummaryQueryParams(BaseModel):
|
||||
timezone: str | None = "utc"
|
||||
timezone: str | None = "UTC"
|
||||
has_clip: int | None = None
|
||||
has_snapshot: int | None = None
|
||||
|
||||
@@ -3,7 +3,7 @@ from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
|
||||
class MediaRecordingsSummaryQueryParams(BaseModel):
|
||||
timezone: str = "utc"
|
||||
timezone: str = "UTC"
|
||||
cameras: str | None = "all"
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class ReviewSummaryQueryParams(BaseModel):
|
||||
cameras: str = "all"
|
||||
labels: str = "all"
|
||||
zones: str = "all"
|
||||
timezone: str = "utc"
|
||||
timezone: str = "UTC"
|
||||
|
||||
|
||||
class ReviewActivityMotionQueryParams(BaseModel):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -75,9 +76,20 @@ def create_fastapi_app(
|
||||
profile_manager: ProfileManager | None = None,
|
||||
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")
|
||||
app = FastAPI(
|
||||
debug=False,
|
||||
lifespan=lifespan,
|
||||
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
|
||||
dependencies=[Depends(require_admin_by_default())]
|
||||
if enforce_default_admin
|
||||
@@ -113,15 +125,6 @@ def create_fastapi_app(
|
||||
database.close()
|
||||
return response
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
logger.info("FastAPI started")
|
||||
asyncio.create_task(
|
||||
debug_replay_auto_stop_watchdog(
|
||||
replay_manager, frigate_config, config_publisher
|
||||
)
|
||||
)
|
||||
|
||||
# Rate limiter (used for login endpoint)
|
||||
if frigate_config.auth.failed_login_rate_limit is None:
|
||||
limiter.enabled = False
|
||||
|
||||
@@ -11,10 +11,10 @@ from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path as FilePath
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytz
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from pathvalidate import sanitize_filename
|
||||
@@ -53,6 +53,7 @@ from frigate.util.file import (
|
||||
)
|
||||
from frigate.util.image import get_image_from_recording, get_image_quality_params
|
||||
from frigate.util.media import get_keyframe_before
|
||||
from frigate.util.time import get_normalized_tz_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -713,7 +714,9 @@ async def vod_hour(
|
||||
parts = year_month.split("-")
|
||||
start_date = (
|
||||
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC)
|
||||
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
|
||||
- datetime.now(
|
||||
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
|
||||
).utcoffset()
|
||||
)
|
||||
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
|
||||
start_ts = start_date.timestamp()
|
||||
|
||||
@@ -5,8 +5,8 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -22,6 +22,7 @@ from frigate.api.defs.response.preview_response import (
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
|
||||
from frigate.models import Previews
|
||||
from frigate.util.time import get_normalized_tz_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -126,7 +127,9 @@ def preview_hour(
|
||||
parts = year_month.split("-")
|
||||
start_date = (
|
||||
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC)
|
||||
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
|
||||
- datetime.now(
|
||||
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
|
||||
).utcoffset()
|
||||
)
|
||||
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
|
||||
start_ts = start_date.timestamp()
|
||||
|
||||
@@ -120,7 +120,7 @@ def all_recordings_summary(
|
||||
@router.get(
|
||||
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
|
||||
)
|
||||
async def recordings_summary(camera_name: str, timezone: str = "utc"):
|
||||
async def recordings_summary(camera_name: str, timezone: str = "UTC"):
|
||||
"""Returns hourly summary for recordings of given camera"""
|
||||
|
||||
time_range_query = (
|
||||
|
||||
@@ -12,8 +12,8 @@ import threading
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import pytz # type: ignore[import-untyped]
|
||||
from peewee import DoesNotExist
|
||||
|
||||
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.util.ffmpeg import run_ffmpeg_with_progress
|
||||
from frigate.util.time import is_current_hour
|
||||
from frigate.util.time import get_normalized_tz_name, is_current_hour
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -371,8 +371,8 @@ class RecordingExporter(threading.Thread):
|
||||
tz_name = self.config.ui.timezone
|
||||
if tz_name:
|
||||
try:
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = ZoneInfo(get_normalized_tz_name(tz_name))
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
tz = None
|
||||
if tz is not None:
|
||||
return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime(
|
||||
@@ -533,8 +533,8 @@ class RecordingExporter(threading.Thread):
|
||||
tz: datetime.tzinfo | None = None
|
||||
if tz_name:
|
||||
try:
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = ZoneInfo(get_normalized_tz_name(tz_name))
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
tz = None
|
||||
if tz is None:
|
||||
tz = datetime.UTC
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Unit tests for recordings/media API endpoints."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from fastapi import Request
|
||||
|
||||
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
|
||||
Clocks spring forward from 2:00 AM to 3:00 AM (EST to EDT)
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
tz = ZoneInfo("America/New_York")
|
||||
|
||||
# March 9, 2024 at 12:00 PM EST (before DST)
|
||||
march_9_noon = tz.localize(datetime(2024, 3, 9, 12, 0, 0)).timestamp()
|
||||
march_9_noon = datetime(2024, 3, 9, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# March 10, 2024 at 12:00 PM EDT (after DST transition)
|
||||
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
|
||||
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# March 11, 2024 at 12:00 PM EDT (after DST)
|
||||
march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp()
|
||||
march_11_noon = datetime(2024, 3, 11, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for each day
|
||||
@@ -124,19 +124,16 @@ class TestHttpMedia(BaseTestHttp):
|
||||
In 2024, DST in America/New_York transitions on November 3, 2024 at 2:00 AM
|
||||
Clocks fall back from 2:00 AM to 1:00 AM (EDT to EST)
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
tz = ZoneInfo("America/New_York")
|
||||
|
||||
# November 2, 2024 at 12:00 PM EDT (before DST transition)
|
||||
nov_2_noon = tz.localize(datetime(2024, 11, 2, 12, 0, 0)).timestamp()
|
||||
nov_2_noon = datetime(2024, 11, 2, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# November 3, 2024 at 12:00 PM EST (after DST transition)
|
||||
# Need to specify is_dst=False to get the time after fall back
|
||||
nov_3_noon = tz.localize(
|
||||
datetime(2024, 11, 3, 12, 0, 0), is_dst=False
|
||||
).timestamp()
|
||||
nov_3_noon = datetime(2024, 11, 3, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# November 4, 2024 at 12:00 PM EST (after DST)
|
||||
nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp()
|
||||
nov_4_noon = datetime(2024, 11, 4, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for each day
|
||||
@@ -197,13 +194,13 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings summary with multiple cameras across DST boundary.
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
tz = ZoneInfo("America/New_York")
|
||||
|
||||
# March 9, 2024 at 10:00 AM EST (before DST)
|
||||
march_9_morning = tz.localize(datetime(2024, 3, 9, 10, 0, 0)).timestamp()
|
||||
march_9_morning = datetime(2024, 3, 9, 10, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# March 10, 2024 at 3:00 PM EDT (after DST transition)
|
||||
march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp()
|
||||
march_10_afternoon = datetime(2024, 3, 10, 15, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Override allowed cameras for this test to include both
|
||||
@@ -266,15 +263,15 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings that span the exact DST transition time.
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
tz = ZoneInfo("America/New_York")
|
||||
|
||||
# March 10, 2024 at 1:00 AM EST (1 hour before DST transition)
|
||||
# At 2:00 AM, clocks jump to 3:00 AM
|
||||
before_transition = tz.localize(datetime(2024, 3, 10, 1, 0, 0)).timestamp()
|
||||
before_transition = datetime(2024, 3, 10, 1, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# Recording that spans the transition (1:00 AM to 3:30 AM EDT)
|
||||
# This is 1.5 hours of actual time but spans the "missing" hour
|
||||
after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp()
|
||||
after_transition = datetime(2024, 3, 10, 3, 30, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
Recordings.insert(
|
||||
@@ -334,7 +331,7 @@ class TestHttpMedia(BaseTestHttp):
|
||||
|
||||
# Test with UTC timezone
|
||||
response = client.get(
|
||||
"/recordings/summary", params={"timezone": "utc", "cameras": "all"}
|
||||
"/recordings/summary", params={"timezone": "UTC", "cameras": "all"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -365,8 +362,8 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings summary filtered to a single camera.
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
|
||||
tz = ZoneInfo("America/New_York")
|
||||
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for both cameras
|
||||
|
||||
@@ -250,7 +250,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
"cameras": "front_door",
|
||||
"labels": "all",
|
||||
"zones": "all",
|
||||
"timezone": "utc",
|
||||
"timezone": "UTC",
|
||||
}
|
||||
response = client.get("/review/summary", params=params)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -2,7 +2,7 @@ import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import numpy as np
|
||||
from pydantic import parse_obj_as
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import frigate.detectors as detectors
|
||||
import frigate.object_detection.base
|
||||
@@ -19,8 +19,8 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
"frigate.detectors.api_types",
|
||||
{det_type: Mock() for det_type in DetectorTypeEnum},
|
||||
):
|
||||
test_cfg = parse_obj_as(
|
||||
DetectorConfig, ({"type": det_type, "model": {}})
|
||||
test_cfg = TypeAdapter(DetectorConfig).validate_python(
|
||||
{"type": det_type, "model": {}}
|
||||
)
|
||||
test_cfg.model.path = "/test/modelpath"
|
||||
test_obj = frigate.object_detection.base.LocalObjectDetector(
|
||||
@@ -44,7 +44,9 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
TEST_DATA = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32])
|
||||
test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
|
||||
detector_config=parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
|
||||
detector_config=TypeAdapter(DetectorConfig).validate_python(
|
||||
{"type": "cpu", "model": {}}
|
||||
)
|
||||
)
|
||||
|
||||
mock_det_api = mock_cputfl.return_value
|
||||
@@ -67,7 +69,9 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
TEST_DATA = np.zeros((1, 32, 32, 3), np.uint8)
|
||||
TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32])
|
||||
|
||||
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
|
||||
test_cfg = TypeAdapter(DetectorConfig).validate_python(
|
||||
{"type": "cpu", "model": {}}
|
||||
)
|
||||
test_cfg.model.input_tensor = InputTensorEnum.nchw
|
||||
|
||||
test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
|
||||
@@ -116,7 +120,9 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
"label-5",
|
||||
]
|
||||
|
||||
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
|
||||
test_cfg = TypeAdapter(DetectorConfig).validate_python(
|
||||
{"type": "cpu", "model": {}}
|
||||
)
|
||||
test_cfg.model = ModelConfig()
|
||||
test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
|
||||
detector_config=test_cfg,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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")
|
||||
+23
-9
@@ -2,17 +2,31 @@
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from zoneinfo import ZoneInfoNotFoundError
|
||||
from typing import Final
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
|
||||
|
||||
import pytz
|
||||
from tzlocal import get_localzone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEZONE_CASEFOLD_INDEX: Final = {
|
||||
timezone.casefold(): timezone for timezone in available_timezones()
|
||||
}
|
||||
|
||||
|
||||
def get_normalized_tz_name(tz_name: str) -> str:
|
||||
tz = TIMEZONE_CASEFOLD_INDEX.get(tz_name.casefold())
|
||||
if tz:
|
||||
return tz
|
||||
|
||||
raise ValueError(f"Unknown timezone: {tz_name}")
|
||||
|
||||
|
||||
def get_tz_modifiers(tz_name: str) -> tuple[str, str, float]:
|
||||
seconds_offset = (
|
||||
datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()
|
||||
datetime.datetime.now(ZoneInfo(get_normalized_tz_name(tz_name)))
|
||||
.utcoffset()
|
||||
.total_seconds()
|
||||
)
|
||||
hours_offset = int(seconds_offset / 60 / 60)
|
||||
minutes_offset = int(seconds_offset / 60 - hours_offset * 60)
|
||||
@@ -25,7 +39,7 @@ def get_tomorrow_at_time(hour: int) -> datetime.datetime:
|
||||
"""Returns the datetime of the following day at 2am."""
|
||||
try:
|
||||
tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1)
|
||||
except ZoneInfoNotFoundError:
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
tomorrow = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
|
||||
logger.warning(
|
||||
"Using utc for maintenance due to missing or incorrect timezone set"
|
||||
@@ -45,7 +59,7 @@ def is_current_hour(timestamp: int) -> bool:
|
||||
|
||||
def get_dst_transitions(
|
||||
tz_name: str, start_time: float, end_time: float
|
||||
) -> list[tuple[float, float]]:
|
||||
) -> list[tuple[float, float, int]]:
|
||||
"""
|
||||
Find DST transition points and return time periods with consistent offsets.
|
||||
|
||||
@@ -59,8 +73,8 @@ def get_dst_transitions(
|
||||
continuous periods with the same UTC offset
|
||||
"""
|
||||
try:
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = ZoneInfo(get_normalized_tz_name(tz_name))
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
# If timezone is invalid, return single period with no offset
|
||||
return [(start_time, end_time, 0)]
|
||||
|
||||
@@ -68,14 +82,14 @@ def get_dst_transitions(
|
||||
current = start_time
|
||||
|
||||
# Get initial offset
|
||||
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
|
||||
dt = datetime.datetime.fromtimestamp(current, tz=datetime.UTC)
|
||||
local_dt = dt.astimezone(tz)
|
||||
prev_offset = local_dt.utcoffset().total_seconds()
|
||||
period_start = start_time
|
||||
|
||||
# Check each day for offset changes
|
||||
while current <= end_time:
|
||||
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
|
||||
dt = datetime.datetime.fromtimestamp(current, tz=datetime.UTC)
|
||||
local_dt = dt.astimezone(tz)
|
||||
current_offset = local_dt.utcoffset().total_seconds()
|
||||
|
||||
|
||||
@@ -1490,7 +1490,14 @@
|
||||
"keyLabel": "Key",
|
||||
"valueLabel": "Value",
|
||||
"keyPlaceholder": "New key",
|
||||
"remove": "Remove"
|
||||
"remove": "Remove",
|
||||
"providerNameLabel": "Provider name",
|
||||
"providerNamePlaceholder": "e.g., openai",
|
||||
"variableNameLabel": "Variable name",
|
||||
"variableNamePlaceholder": "e.g., MY_VARIABLE",
|
||||
"loggerNameLabel": "Logger name",
|
||||
"loggerNamePlaceholder": "e.g., frigate.record",
|
||||
"keyPatternError": "Use only letters, numbers, hyphens, and underscores (no spaces)"
|
||||
},
|
||||
"knownPlates": {
|
||||
"namePlaceholder": "e.g., Wife's Car",
|
||||
|
||||
@@ -7,7 +7,13 @@ const environmentVars: SectionConfigOverrides = {
|
||||
advancedFields: [],
|
||||
uiSchema: {
|
||||
additionalProperties: {
|
||||
"ui:options": { size: "lg" },
|
||||
"ui:options": {
|
||||
size: "lg",
|
||||
additionalPropertyKeyLabel:
|
||||
"configForm.additionalProperties.variableNameLabel",
|
||||
additionalPropertyKeyPlaceholder:
|
||||
"configForm.additionalProperties.variableNamePlaceholder",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,7 +9,15 @@ const genai: SectionConfigOverrides = {
|
||||
uiSchema: {
|
||||
"ui:options": { disableNestedCard: true },
|
||||
"*": {
|
||||
"ui:options": { disableNestedCard: true },
|
||||
"ui:options": {
|
||||
disableNestedCard: true,
|
||||
additionalPropertyKeyLabel:
|
||||
"configForm.additionalProperties.providerNameLabel",
|
||||
additionalPropertyKeyPlaceholder:
|
||||
"configForm.additionalProperties.providerNamePlaceholder",
|
||||
additionalPropertyKeyPattern: "^[a-zA-Z0-9_-]+$",
|
||||
preventKeyRename: true,
|
||||
},
|
||||
"ui:order": [
|
||||
"provider",
|
||||
"api_key",
|
||||
|
||||
@@ -12,7 +12,13 @@ const logger: SectionConfigOverrides = {
|
||||
},
|
||||
logs: {
|
||||
additionalProperties: {
|
||||
"ui:options": { enumI18nPrefix: "logger.logLevel" },
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "logger.logLevel",
|
||||
additionalPropertyKeyLabel:
|
||||
"configForm.additionalProperties.loggerNameLabel",
|
||||
additionalPropertyKeyPlaceholder:
|
||||
"configForm.additionalProperties.loggerNamePlaceholder",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
StrictRJSFSchema,
|
||||
WrapIfAdditionalTemplateProps,
|
||||
} from "@rjsf/utils";
|
||||
import { useEffect, useMemo, useState, type FocusEvent } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTrash2 } from "react-icons/lu";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
export function WrapIfAdditionalTemplate<
|
||||
T = unknown,
|
||||
@@ -30,6 +32,7 @@ export function WrapIfAdditionalTemplate<
|
||||
onKeyRenameBlur,
|
||||
readonly,
|
||||
required,
|
||||
registry,
|
||||
schema,
|
||||
uiSchema,
|
||||
} = props;
|
||||
@@ -38,6 +41,55 @@ export function WrapIfAdditionalTemplate<
|
||||
|
||||
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
|
||||
|
||||
const uiOptions = getUiOptions(uiSchema);
|
||||
const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true;
|
||||
|
||||
const keyLabelKey =
|
||||
typeof uiOptions.additionalPropertyKeyLabel === "string"
|
||||
? uiOptions.additionalPropertyKeyLabel
|
||||
: undefined;
|
||||
const keyPlaceholderKey =
|
||||
typeof uiOptions.additionalPropertyKeyPlaceholder === "string"
|
||||
? uiOptions.additionalPropertyKeyPlaceholder
|
||||
: undefined;
|
||||
const keyPattern =
|
||||
typeof uiOptions.additionalPropertyKeyPattern === "string"
|
||||
? uiOptions.additionalPropertyKeyPattern
|
||||
: undefined;
|
||||
const preventKeyRename = uiOptions.preventKeyRename === true;
|
||||
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
|
||||
// optionally, lock the key once it's been saved
|
||||
const baseline = formContext?.baselineFormData;
|
||||
const keyLocked =
|
||||
preventKeyRename &&
|
||||
typeof label === "string" &&
|
||||
!!baseline &&
|
||||
Object.prototype.hasOwnProperty.call(baseline, label);
|
||||
|
||||
// controlled key value so we can validate live and block invalid renames.
|
||||
const [keyValue, setKeyValue] = useState<string>(label ?? "");
|
||||
useEffect(() => {
|
||||
setKeyValue(label ?? "");
|
||||
}, [label]);
|
||||
|
||||
const keyRegex = useMemo(
|
||||
() => (keyPattern ? new RegExp(keyPattern) : undefined),
|
||||
[keyPattern],
|
||||
);
|
||||
const keyError = useMemo(() => {
|
||||
if (!keyRegex || keyLocked) return null;
|
||||
if (!keyRegex.test(keyValue)) {
|
||||
return t("configForm.additionalProperties.keyPatternError", {
|
||||
ns: "views/settings",
|
||||
defaultValue:
|
||||
"Use only letters, numbers, hyphens, and underscores (no spaces)",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [keyRegex, keyLocked, keyValue, t]);
|
||||
|
||||
if (!additional) {
|
||||
return (
|
||||
<div className={classNames} style={style}>
|
||||
@@ -47,20 +99,26 @@ export function WrapIfAdditionalTemplate<
|
||||
}
|
||||
|
||||
const keyId = `${id}-key`;
|
||||
const keyLabel = t("configForm.additionalProperties.keyLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const keyLabel = keyLabelKey
|
||||
? t(keyLabelKey, { ns: "views/settings" })
|
||||
: t("configForm.additionalProperties.keyLabel", { ns: "views/settings" });
|
||||
const valueLabel = t("configForm.additionalProperties.valueLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const keyPlaceholder = t("configForm.additionalProperties.keyPlaceholder", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const keyPlaceholder = keyPlaceholderKey
|
||||
? t(keyPlaceholderKey, { ns: "views/settings" })
|
||||
: t("configForm.additionalProperties.keyPlaceholder", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const removeLabel = t("configForm.additionalProperties.remove", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const uiOptions = getUiOptions(uiSchema);
|
||||
const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true;
|
||||
|
||||
const commitKeyRename = (e: FocusEvent<HTMLInputElement>) => {
|
||||
if (readonly) return;
|
||||
if (keyError) return;
|
||||
onKeyRenameBlur?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -70,23 +128,30 @@ export function WrapIfAdditionalTemplate<
|
||||
{!keyIsReadonly && (
|
||||
<div className="col-span-12 space-y-2 md:col-span-2">
|
||||
{displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>}
|
||||
{keyIsReadonly ? (
|
||||
{keyLocked ? (
|
||||
<div
|
||||
id={keyId}
|
||||
className="flex items-center text-sm text-muted-foreground"
|
||||
className="flex items-center break-all text-sm text-primary-variant"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id={keyId}
|
||||
name={keyId}
|
||||
required={required}
|
||||
defaultValue={label}
|
||||
placeholder={keyPlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onBlur={!readonly ? onKeyRenameBlur : undefined}
|
||||
/>
|
||||
<>
|
||||
<Input
|
||||
id={keyId}
|
||||
name={keyId}
|
||||
required={required}
|
||||
value={keyValue}
|
||||
placeholder={keyPlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(e) => setKeyValue(e.target.value)}
|
||||
onBlur={!readonly ? commitKeyRename : undefined}
|
||||
aria-invalid={keyError ? true : undefined}
|
||||
/>
|
||||
{keyError && (
|
||||
<p className="text-xs text-destructive">{keyError}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user