mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
Compare commits
4 Commits
d78d6398e1
...
a82e93cd73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a82e93cd73 | ||
|
|
c2e667c0dd | ||
|
|
c9bd907721 | ||
|
|
318c3d0503 |
@ -589,23 +589,38 @@ def config_set(request: Request, body: AppConfigSetBody):
|
|||||||
request.app.frigate_config = config
|
request.app.frigate_config = config
|
||||||
request.app.genai_manager.update_config(config)
|
request.app.genai_manager.update_config(config)
|
||||||
|
|
||||||
|
if request.app.stats_emitter is not None:
|
||||||
|
request.app.stats_emitter.config = config
|
||||||
|
|
||||||
if body.update_topic:
|
if body.update_topic:
|
||||||
if body.update_topic.startswith("config/cameras/"):
|
if body.update_topic.startswith("config/cameras/"):
|
||||||
_, _, camera, field = body.update_topic.split("/")
|
_, _, camera, field = body.update_topic.split("/")
|
||||||
|
|
||||||
if field == "add":
|
if camera == "*":
|
||||||
settings = config.cameras[camera]
|
# Wildcard: fan out update to all cameras
|
||||||
elif field == "remove":
|
enum_value = CameraConfigUpdateEnum[field]
|
||||||
settings = old_config.cameras[camera]
|
for camera_name in config.cameras:
|
||||||
|
settings = config.get_nested_object(
|
||||||
|
f"config/cameras/{camera_name}/{field}"
|
||||||
|
)
|
||||||
|
request.app.config_publisher.publish_update(
|
||||||
|
CameraConfigUpdateTopic(enum_value, camera_name),
|
||||||
|
settings,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
settings = config.get_nested_object(body.update_topic)
|
if field == "add":
|
||||||
|
settings = config.cameras[camera]
|
||||||
|
elif field == "remove":
|
||||||
|
settings = old_config.cameras[camera]
|
||||||
|
else:
|
||||||
|
settings = config.get_nested_object(body.update_topic)
|
||||||
|
|
||||||
request.app.config_publisher.publish_update(
|
request.app.config_publisher.publish_update(
|
||||||
CameraConfigUpdateTopic(
|
CameraConfigUpdateTopic(
|
||||||
CameraConfigUpdateEnum[field], camera
|
CameraConfigUpdateEnum[field], camera
|
||||||
),
|
),
|
||||||
settings,
|
settings,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Generic handling for global config updates
|
# Generic handling for global config updates
|
||||||
settings = config.get_nested_object(body.update_topic)
|
settings = config.get_nested_object(body.update_topic)
|
||||||
|
|||||||
@ -1281,6 +1281,13 @@ def preview_gif(
|
|||||||
else:
|
else:
|
||||||
# need to generate from existing images
|
# need to generate from existing images
|
||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
|
|
||||||
|
if not os.path.isdir(preview_dir):
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "Preview not found"},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
file_start = f"preview_{camera_name}"
|
file_start = f"preview_{camera_name}"
|
||||||
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
|
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
|
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
@ -1456,6 +1463,13 @@ def preview_mp4(
|
|||||||
else:
|
else:
|
||||||
# need to generate from existing images
|
# need to generate from existing images
|
||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
|
|
||||||
|
if not os.path.isdir(preview_dir):
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "Preview not found"},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
file_start = f"preview_{camera_name}"
|
file_start = f"preview_{camera_name}"
|
||||||
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
|
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
|
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
|
|||||||
@ -242,6 +242,14 @@ class CameraConfig(FrigateBaseModel):
|
|||||||
def create_ffmpeg_cmds(self):
|
def create_ffmpeg_cmds(self):
|
||||||
if "_ffmpeg_cmds" in self:
|
if "_ffmpeg_cmds" in self:
|
||||||
return
|
return
|
||||||
|
self._build_ffmpeg_cmds()
|
||||||
|
|
||||||
|
def recreate_ffmpeg_cmds(self):
|
||||||
|
"""Force regeneration of ffmpeg commands from current config."""
|
||||||
|
self._build_ffmpeg_cmds()
|
||||||
|
|
||||||
|
def _build_ffmpeg_cmds(self):
|
||||||
|
"""Build ffmpeg commands from the current ffmpeg config."""
|
||||||
ffmpeg_cmds = []
|
ffmpeg_cmds = []
|
||||||
for ffmpeg_input in self.ffmpeg.inputs:
|
for ffmpeg_input in self.ffmpeg.inputs:
|
||||||
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)
|
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
|||||||
birdseye = "birdseye"
|
birdseye = "birdseye"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
enabled = "enabled"
|
enabled = "enabled"
|
||||||
|
ffmpeg = "ffmpeg"
|
||||||
motion = "motion" # includes motion and motion masks
|
motion = "motion" # includes motion and motion masks
|
||||||
notifications = "notifications"
|
notifications = "notifications"
|
||||||
objects = "objects"
|
objects = "objects"
|
||||||
@ -91,6 +92,9 @@ class CameraConfigUpdateSubscriber:
|
|||||||
|
|
||||||
if update_type == CameraConfigUpdateEnum.audio:
|
if update_type == CameraConfigUpdateEnum.audio:
|
||||||
config.audio = updated_config
|
config.audio = updated_config
|
||||||
|
elif update_type == CameraConfigUpdateEnum.ffmpeg:
|
||||||
|
config.ffmpeg = updated_config
|
||||||
|
config.recreate_ffmpeg_cmds()
|
||||||
elif update_type == CameraConfigUpdateEnum.audio_transcription:
|
elif update_type == CameraConfigUpdateEnum.audio_transcription:
|
||||||
config.audio_transcription = updated_config
|
config.audio_transcription = updated_config
|
||||||
elif update_type == CameraConfigUpdateEnum.birdseye:
|
elif update_type == CameraConfigUpdateEnum.birdseye:
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
|||||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||||
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.classification import LicensePlateRecognitionConfig
|
||||||
from frigate.data_processing.common.license_plate.mixin import (
|
from frigate.data_processing.common.license_plate.mixin import (
|
||||||
WRITE_DEBUG_IMAGES,
|
WRITE_DEBUG_IMAGES,
|
||||||
LicensePlateProcessingMixin,
|
LicensePlateProcessingMixin,
|
||||||
@ -47,6 +48,11 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
|||||||
self.sub_label_publisher = sub_label_publisher
|
self.sub_label_publisher = sub_label_publisher
|
||||||
super().__init__(config, metrics, model_runner)
|
super().__init__(config, metrics, model_runner)
|
||||||
|
|
||||||
|
def update_config(self, lpr_config: LicensePlateRecognitionConfig) -> None:
|
||||||
|
"""Update LPR config at runtime."""
|
||||||
|
self.lpr_config = lpr_config
|
||||||
|
logger.debug("LPR config updated dynamically")
|
||||||
|
|
||||||
def process_data(
|
def process_data(
|
||||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from frigate.comms.event_metadata_updater import (
|
|||||||
)
|
)
|
||||||
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.classification import FaceRecognitionConfig
|
||||||
from frigate.const import FACE_DIR, MODEL_CACHE_DIR
|
from frigate.const import FACE_DIR, MODEL_CACHE_DIR
|
||||||
from frigate.data_processing.common.face.model import (
|
from frigate.data_processing.common.face.model import (
|
||||||
ArcFaceRecognizer,
|
ArcFaceRecognizer,
|
||||||
@ -95,6 +96,11 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
|||||||
|
|
||||||
self.recognizer.build()
|
self.recognizer.build()
|
||||||
|
|
||||||
|
def update_config(self, face_config: FaceRecognitionConfig) -> None:
|
||||||
|
"""Update face recognition config at runtime."""
|
||||||
|
self.face_config = face_config
|
||||||
|
logger.debug("Face recognition config updated dynamically")
|
||||||
|
|
||||||
def __download_models(self, path: str) -> None:
|
def __download_models(self, path: str) -> None:
|
||||||
try:
|
try:
|
||||||
file_name = os.path.basename(path)
|
file_name = os.path.basename(path)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import numpy as np
|
|||||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||||
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.classification import LicensePlateRecognitionConfig
|
||||||
from frigate.data_processing.common.license_plate.mixin import (
|
from frigate.data_processing.common.license_plate.mixin import (
|
||||||
LicensePlateProcessingMixin,
|
LicensePlateProcessingMixin,
|
||||||
)
|
)
|
||||||
@ -40,6 +41,11 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
|
|||||||
self.camera_current_cars: dict[str, list[str]] = {}
|
self.camera_current_cars: dict[str, list[str]] = {}
|
||||||
super().__init__(config, metrics)
|
super().__init__(config, metrics)
|
||||||
|
|
||||||
|
def update_config(self, lpr_config: LicensePlateRecognitionConfig) -> None:
|
||||||
|
"""Update LPR config at runtime."""
|
||||||
|
self.lpr_config = lpr_config
|
||||||
|
logger.debug("LPR config updated dynamically")
|
||||||
|
|
||||||
def process_frame(
|
def process_frame(
|
||||||
self,
|
self,
|
||||||
obj_data: dict[str, Any],
|
obj_data: dict[str, Any],
|
||||||
|
|||||||
@ -99,6 +99,13 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self.classification_config_subscriber = ConfigSubscriber(
|
self.classification_config_subscriber = ConfigSubscriber(
|
||||||
"config/classification/custom/"
|
"config/classification/custom/"
|
||||||
)
|
)
|
||||||
|
self.bird_classification_config_subscriber = ConfigSubscriber(
|
||||||
|
"config/classification", exact=True
|
||||||
|
)
|
||||||
|
self.face_recognition_config_subscriber = ConfigSubscriber(
|
||||||
|
"config/face_recognition", exact=True
|
||||||
|
)
|
||||||
|
self.lpr_config_subscriber = ConfigSubscriber("config/lpr", exact=True)
|
||||||
|
|
||||||
# Configure Frigate DB
|
# Configure Frigate DB
|
||||||
db = SqliteVecQueueDatabase(
|
db = SqliteVecQueueDatabase(
|
||||||
@ -273,6 +280,9 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
self.config_updater.check_for_updates()
|
self.config_updater.check_for_updates()
|
||||||
self._check_classification_config_updates()
|
self._check_classification_config_updates()
|
||||||
|
self._check_bird_classification_config_updates()
|
||||||
|
self._check_face_recognition_config_updates()
|
||||||
|
self._check_lpr_config_updates()
|
||||||
self._process_requests()
|
self._process_requests()
|
||||||
self._process_updates()
|
self._process_updates()
|
||||||
self._process_recordings_updates()
|
self._process_recordings_updates()
|
||||||
@ -284,6 +294,9 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
self.config_updater.stop()
|
self.config_updater.stop()
|
||||||
self.classification_config_subscriber.stop()
|
self.classification_config_subscriber.stop()
|
||||||
|
self.bird_classification_config_subscriber.stop()
|
||||||
|
self.face_recognition_config_subscriber.stop()
|
||||||
|
self.lpr_config_subscriber.stop()
|
||||||
self.event_subscriber.stop()
|
self.event_subscriber.stop()
|
||||||
self.event_end_subscriber.stop()
|
self.event_end_subscriber.stop()
|
||||||
self.recordings_subscriber.stop()
|
self.recordings_subscriber.stop()
|
||||||
@ -356,6 +369,62 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
f"Added classification processor for model: {model_name} (type: {type(processor).__name__})"
|
f"Added classification processor for model: {model_name} (type: {type(processor).__name__})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _check_bird_classification_config_updates(self) -> None:
|
||||||
|
"""Check for bird classification config updates."""
|
||||||
|
topic, classification_config = (
|
||||||
|
self.bird_classification_config_subscriber.check_for_update()
|
||||||
|
)
|
||||||
|
|
||||||
|
if topic is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.config.classification = classification_config
|
||||||
|
logger.debug("Applied dynamic bird classification config update")
|
||||||
|
|
||||||
|
def _check_face_recognition_config_updates(self) -> None:
|
||||||
|
"""Check for face recognition config updates."""
|
||||||
|
topic, face_config = self.face_recognition_config_subscriber.check_for_update()
|
||||||
|
|
||||||
|
if topic is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
previous_min_area = self.config.face_recognition.min_area
|
||||||
|
self.config.face_recognition = face_config
|
||||||
|
|
||||||
|
for camera_config in self.config.cameras.values():
|
||||||
|
if camera_config.face_recognition.min_area == previous_min_area:
|
||||||
|
camera_config.face_recognition.min_area = face_config.min_area
|
||||||
|
|
||||||
|
for processor in self.realtime_processors:
|
||||||
|
if isinstance(processor, FaceRealTimeProcessor):
|
||||||
|
processor.update_config(face_config)
|
||||||
|
|
||||||
|
logger.debug("Applied dynamic face recognition config update")
|
||||||
|
|
||||||
|
def _check_lpr_config_updates(self) -> None:
|
||||||
|
"""Check for LPR config updates."""
|
||||||
|
topic, lpr_config = self.lpr_config_subscriber.check_for_update()
|
||||||
|
|
||||||
|
if topic is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
previous_min_area = self.config.lpr.min_area
|
||||||
|
self.config.lpr = lpr_config
|
||||||
|
|
||||||
|
for camera_config in self.config.cameras.values():
|
||||||
|
if camera_config.lpr.min_area == previous_min_area:
|
||||||
|
camera_config.lpr.min_area = lpr_config.min_area
|
||||||
|
|
||||||
|
for processor in self.realtime_processors:
|
||||||
|
if isinstance(processor, LicensePlateRealTimeProcessor):
|
||||||
|
processor.update_config(lpr_config)
|
||||||
|
|
||||||
|
for processor in self.post_processors:
|
||||||
|
if isinstance(processor, LicensePlatePostProcessor):
|
||||||
|
processor.update_config(lpr_config)
|
||||||
|
|
||||||
|
logger.debug("Applied dynamic LPR config update")
|
||||||
|
|
||||||
def _process_requests(self) -> None:
|
def _process_requests(self) -> None:
|
||||||
"""Process embeddings requests"""
|
"""Process embeddings requests"""
|
||||||
|
|
||||||
|
|||||||
@ -273,17 +273,13 @@ class BirdsEyeFrameManager:
|
|||||||
stop_event: mp.Event,
|
stop_event: mp.Event,
|
||||||
):
|
):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.mode = config.birdseye.mode
|
|
||||||
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
|
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
|
||||||
self.frame_shape = (height, width)
|
self.frame_shape = (height, width)
|
||||||
self.yuv_shape = (height * 3 // 2, width)
|
self.yuv_shape = (height * 3 // 2, width)
|
||||||
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
|
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
|
||||||
self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor)
|
self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor)
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.inactivity_threshold = config.birdseye.inactivity_threshold
|
self.last_refresh_time = 0
|
||||||
|
|
||||||
if config.birdseye.layout.max_cameras:
|
|
||||||
self.last_refresh_time = 0
|
|
||||||
|
|
||||||
# initialize the frame as black and with the Frigate logo
|
# initialize the frame as black and with the Frigate logo
|
||||||
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
|
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
|
||||||
@ -426,7 +422,7 @@ class BirdsEyeFrameManager:
|
|||||||
and self.config.cameras[cam].enabled
|
and self.config.cameras[cam].enabled
|
||||||
and cam_data["last_active_frame"] > 0
|
and cam_data["last_active_frame"] > 0
|
||||||
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
|
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
|
||||||
< self.inactivity_threshold
|
< self.config.birdseye.inactivity_threshold
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
logger.debug(f"Active cameras: {active_cameras}")
|
logger.debug(f"Active cameras: {active_cameras}")
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from ws4py.server.wsgirefserver import (
|
|||||||
)
|
)
|
||||||
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
||||||
|
|
||||||
|
from frigate.comms.config_updater import ConfigSubscriber
|
||||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||||
from frigate.comms.ws import WebSocket
|
from frigate.comms.ws import WebSocket
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
@ -138,6 +139,7 @@ class OutputProcess(FrigateProcess):
|
|||||||
CameraConfigUpdateEnum.record,
|
CameraConfigUpdateEnum.record,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
|
||||||
|
|
||||||
jsmpeg_cameras: dict[str, JsmpegCamera] = {}
|
jsmpeg_cameras: dict[str, JsmpegCamera] = {}
|
||||||
birdseye: Birdseye | None = None
|
birdseye: Birdseye | None = None
|
||||||
@ -167,6 +169,20 @@ class OutputProcess(FrigateProcess):
|
|||||||
websocket_thread.start()
|
websocket_thread.start()
|
||||||
|
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
|
update_topic, birdseye_config = (
|
||||||
|
birdseye_config_subscriber.check_for_update()
|
||||||
|
)
|
||||||
|
|
||||||
|
if update_topic is not None:
|
||||||
|
previous_global_mode = self.config.birdseye.mode
|
||||||
|
self.config.birdseye = birdseye_config
|
||||||
|
|
||||||
|
for camera_config in self.config.cameras.values():
|
||||||
|
if camera_config.birdseye.mode == previous_global_mode:
|
||||||
|
camera_config.birdseye.mode = birdseye_config.mode
|
||||||
|
|
||||||
|
logger.debug("Applied dynamic birdseye config update")
|
||||||
|
|
||||||
# check if there is an updated config
|
# check if there is an updated config
|
||||||
updates = config_subscriber.check_for_updates()
|
updates = config_subscriber.check_for_updates()
|
||||||
|
|
||||||
@ -297,6 +313,7 @@ class OutputProcess(FrigateProcess):
|
|||||||
birdseye.stop()
|
birdseye.stop()
|
||||||
|
|
||||||
config_subscriber.stop()
|
config_subscriber.stop()
|
||||||
|
birdseye_config_subscriber.stop()
|
||||||
websocket_server.manager.close_all()
|
websocket_server.manager.close_all()
|
||||||
websocket_server.manager.stop()
|
websocket_server.manager.stop()
|
||||||
websocket_server.manager.join()
|
websocket_server.manager.join()
|
||||||
|
|||||||
261
frigate/test/http_api/test_http_config_set.py
Normal file
261
frigate/test/http_api/test_http_config_set.py
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
"""Tests for the config_set endpoint's wildcard camera propagation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import ruamel.yaml
|
||||||
|
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.camera.updater import (
|
||||||
|
CameraConfigUpdateEnum,
|
||||||
|
CameraConfigUpdatePublisher,
|
||||||
|
CameraConfigUpdateTopic,
|
||||||
|
)
|
||||||
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||||
|
"""Test that wildcard camera updates fan out to all cameras."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||||
|
self.minimal_config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"cameras": {
|
||||||
|
"front_door": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"height": 1080,
|
||||||
|
"width": 1920,
|
||||||
|
"fps": 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"back_yard": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"height": 720,
|
||||||
|
"width": 1280,
|
||||||
|
"fps": 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _create_app_with_publisher(self):
|
||||||
|
"""Create app with a mocked config publisher."""
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||||
|
from frigate.api.fastapi_app import create_fastapi_app
|
||||||
|
|
||||||
|
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||||
|
mock_publisher.publisher = MagicMock()
|
||||||
|
|
||||||
|
app = create_fastapi_app(
|
||||||
|
FrigateConfig(**self.minimal_config),
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
mock_publisher,
|
||||||
|
None,
|
||||||
|
enforce_default_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mock_get_current_user(request: Request):
|
||||||
|
username = request.headers.get("remote-user")
|
||||||
|
role = request.headers.get("remote-role")
|
||||||
|
return {"username": username, "role": role}
|
||||||
|
|
||||||
|
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||||
|
return list(self.minimal_config.get("cameras", {}).keys())
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||||
|
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||||
|
mock_get_allowed_cameras_for_filter
|
||||||
|
)
|
||||||
|
|
||||||
|
return app, mock_publisher
|
||||||
|
|
||||||
|
def _write_config_file(self):
|
||||||
|
"""Write the minimal config to a temp YAML file and return the path."""
|
||||||
|
yaml = ruamel.yaml.YAML()
|
||||||
|
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
|
||||||
|
yaml.dump(self.minimal_config, f)
|
||||||
|
f.close()
|
||||||
|
return f.name
|
||||||
|
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_wildcard_detect_update_fans_out_to_all_cameras(self, mock_find_config):
|
||||||
|
"""config/cameras/*/detect fans out to all cameras."""
|
||||||
|
config_path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = config_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
app, mock_publisher = self._create_app_with_publisher()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.put(
|
||||||
|
"/config/set",
|
||||||
|
json={
|
||||||
|
"config_data": {"detect": {"fps": 15}},
|
||||||
|
"update_topic": "config/cameras/*/detect",
|
||||||
|
"requires_restart": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertTrue(data["success"])
|
||||||
|
|
||||||
|
# Verify publish_update called for each camera
|
||||||
|
self.assertEqual(mock_publisher.publish_update.call_count, 2)
|
||||||
|
|
||||||
|
published_cameras = set()
|
||||||
|
for c in mock_publisher.publish_update.call_args_list:
|
||||||
|
topic = c[0][0]
|
||||||
|
self.assertIsInstance(topic, CameraConfigUpdateTopic)
|
||||||
|
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.detect)
|
||||||
|
published_cameras.add(topic.camera)
|
||||||
|
|
||||||
|
self.assertEqual(published_cameras, {"front_door", "back_yard"})
|
||||||
|
|
||||||
|
# Global publisher should NOT be called for wildcard
|
||||||
|
mock_publisher.publisher.publish.assert_not_called()
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_wildcard_motion_update_fans_out(self, mock_find_config):
|
||||||
|
"""config/cameras/*/motion fans out to all cameras."""
|
||||||
|
config_path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = config_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
app, mock_publisher = self._create_app_with_publisher()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.put(
|
||||||
|
"/config/set",
|
||||||
|
json={
|
||||||
|
"config_data": {"motion": {"threshold": 30}},
|
||||||
|
"update_topic": "config/cameras/*/motion",
|
||||||
|
"requires_restart": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
published_cameras = set()
|
||||||
|
for c in mock_publisher.publish_update.call_args_list:
|
||||||
|
topic = c[0][0]
|
||||||
|
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.motion)
|
||||||
|
published_cameras.add(topic.camera)
|
||||||
|
|
||||||
|
self.assertEqual(published_cameras, {"front_door", "back_yard"})
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_camera_specific_topic_only_updates_one_camera(self, mock_find_config):
|
||||||
|
"""config/cameras/front_door/detect only updates front_door."""
|
||||||
|
config_path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = config_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
app, mock_publisher = self._create_app_with_publisher()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.put(
|
||||||
|
"/config/set",
|
||||||
|
json={
|
||||||
|
"config_data": {
|
||||||
|
"cameras": {"front_door": {"detect": {"fps": 20}}}
|
||||||
|
},
|
||||||
|
"update_topic": "config/cameras/front_door/detect",
|
||||||
|
"requires_restart": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
# Only one camera updated
|
||||||
|
self.assertEqual(mock_publisher.publish_update.call_count, 1)
|
||||||
|
topic = mock_publisher.publish_update.call_args[0][0]
|
||||||
|
self.assertEqual(topic.camera, "front_door")
|
||||||
|
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.detect)
|
||||||
|
|
||||||
|
# Global publisher should NOT be called
|
||||||
|
mock_publisher.publisher.publish.assert_not_called()
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_wildcard_sends_merged_per_camera_config(self, mock_find_config):
|
||||||
|
"""Wildcard fan-out sends each camera's own merged config."""
|
||||||
|
config_path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = config_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
app, mock_publisher = self._create_app_with_publisher()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.put(
|
||||||
|
"/config/set",
|
||||||
|
json={
|
||||||
|
"config_data": {"detect": {"fps": 15}},
|
||||||
|
"update_topic": "config/cameras/*/detect",
|
||||||
|
"requires_restart": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
for c in mock_publisher.publish_update.call_args_list:
|
||||||
|
camera_detect_config = c[0][1]
|
||||||
|
self.assertIsNotNone(camera_detect_config)
|
||||||
|
self.assertTrue(hasattr(camera_detect_config, "fps"))
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_non_camera_global_topic_uses_generic_publish(self, mock_find_config):
|
||||||
|
"""Non-camera topics (e.g. config/live) use the generic publisher."""
|
||||||
|
config_path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = config_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
app, mock_publisher = self._create_app_with_publisher()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.put(
|
||||||
|
"/config/set",
|
||||||
|
json={
|
||||||
|
"config_data": {"live": {"height": 720}},
|
||||||
|
"update_topic": "config/live",
|
||||||
|
"requires_restart": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
# Global topic publisher called
|
||||||
|
mock_publisher.publisher.publish.assert_called_once()
|
||||||
|
|
||||||
|
# Camera-level publish_update NOT called
|
||||||
|
mock_publisher.publish_update.assert_not_called()
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -151,7 +151,9 @@ def sync_recordings(
|
|||||||
|
|
||||||
max_inserts = 1000
|
max_inserts = 1000
|
||||||
for batch in chunked(recordings_to_delete, max_inserts):
|
for batch in chunked(recordings_to_delete, max_inserts):
|
||||||
RecordingsToDelete.insert_many(batch).execute()
|
RecordingsToDelete.insert_many(
|
||||||
|
[{"id": r["id"]} for r in batch]
|
||||||
|
).execute()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
deleted = (
|
deleted = (
|
||||||
|
|||||||
@ -214,7 +214,11 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||||
None,
|
None,
|
||||||
{config.name: config},
|
{config.name: config},
|
||||||
[CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.record],
|
[
|
||||||
|
CameraConfigUpdateEnum.enabled,
|
||||||
|
CameraConfigUpdateEnum.ffmpeg,
|
||||||
|
CameraConfigUpdateEnum.record,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
self.requestor = InterProcessRequestor()
|
self.requestor = InterProcessRequestor()
|
||||||
self.was_enabled = self.config.enabled
|
self.was_enabled = self.config.enabled
|
||||||
@ -254,9 +258,13 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self._last_record_status = status
|
self._last_record_status = status
|
||||||
self._last_status_update_time = now
|
self._last_status_update_time = now
|
||||||
|
|
||||||
|
def _check_config_updates(self) -> dict[str, list[str]]:
|
||||||
|
"""Check for config updates and return the update dict."""
|
||||||
|
return self.config_subscriber.check_for_updates()
|
||||||
|
|
||||||
def _update_enabled_state(self) -> bool:
|
def _update_enabled_state(self) -> bool:
|
||||||
"""Fetch the latest config and update enabled state."""
|
"""Fetch the latest config and update enabled state."""
|
||||||
self.config_subscriber.check_for_updates()
|
self._check_config_updates()
|
||||||
return self.config.enabled
|
return self.config.enabled
|
||||||
|
|
||||||
def reset_capture_thread(
|
def reset_capture_thread(
|
||||||
@ -317,7 +325,24 @@ class CameraWatchdog(threading.Thread):
|
|||||||
|
|
||||||
# 1 second watchdog loop
|
# 1 second watchdog loop
|
||||||
while not self.stop_event.wait(1):
|
while not self.stop_event.wait(1):
|
||||||
enabled = self._update_enabled_state()
|
updates = self._check_config_updates()
|
||||||
|
|
||||||
|
# Handle ffmpeg config changes by restarting all ffmpeg processes
|
||||||
|
if "ffmpeg" in updates and self.config.enabled:
|
||||||
|
self.logger.debug(
|
||||||
|
"FFmpeg config updated for %s, restarting ffmpeg processes",
|
||||||
|
self.config.name,
|
||||||
|
)
|
||||||
|
self.stop_all_ffmpeg()
|
||||||
|
self.start_all_ffmpeg()
|
||||||
|
self.latest_valid_segment_time = 0
|
||||||
|
self.latest_invalid_segment_time = 0
|
||||||
|
self.latest_cache_segment_time = 0
|
||||||
|
self.record_enable_time = datetime.now().astimezone(timezone.utc)
|
||||||
|
last_restart_time = datetime.now().timestamp()
|
||||||
|
continue
|
||||||
|
|
||||||
|
enabled = self.config.enabled
|
||||||
if enabled != self.was_enabled:
|
if enabled != self.was_enabled:
|
||||||
if enabled:
|
if enabled:
|
||||||
self.logger.debug(f"Enabling camera {self.config.name}")
|
self.logger.debug(f"Enabling camera {self.config.name}")
|
||||||
|
|||||||
35
web/package-lock.json
generated
35
web/package-lock.json
generated
@ -5386,9 +5386,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -5396,13 +5396,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "9.0.4",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@ -9634,9 +9634,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
},
|
},
|
||||||
@ -12106,9 +12107,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -12137,13 +12138,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/test-exclude/node_modules/minimatch": {
|
"node_modules/test-exclude/node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
|||||||
@ -25,14 +25,7 @@ const audio: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: [
|
restartRequired: ["num_threads"],
|
||||||
"enabled",
|
|
||||||
"listen",
|
|
||||||
"filters",
|
|
||||||
"min_volume",
|
|
||||||
"max_not_heard",
|
|
||||||
"num_threads",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
restartRequired: ["num_threads"],
|
restartRequired: ["num_threads"],
|
||||||
|
|||||||
@ -28,10 +28,7 @@ const birdseye: SectionConfigOverrides = {
|
|||||||
"width",
|
"width",
|
||||||
"height",
|
"height",
|
||||||
"quality",
|
"quality",
|
||||||
"mode",
|
|
||||||
"layout.scaling_factor",
|
"layout.scaling_factor",
|
||||||
"inactivity_threshold",
|
|
||||||
"layout.max_cameras",
|
|
||||||
"idle_heartbeat_fps",
|
"idle_heartbeat_fps",
|
||||||
],
|
],
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const classification: SectionConfigOverrides = {
|
const classification: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/custom_classification/object_classification",
|
sectionDocs: "/configuration/custom_classification/object_classification",
|
||||||
restartRequired: ["bird.enabled", "bird.threshold"],
|
restartRequired: ["bird.enabled"],
|
||||||
hiddenFields: ["custom"],
|
hiddenFields: ["custom"],
|
||||||
advancedFields: [],
|
advancedFields: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -30,16 +30,7 @@ const detect: SectionConfigOverrides = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: [
|
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
||||||
"enabled",
|
|
||||||
"width",
|
|
||||||
"height",
|
|
||||||
"fps",
|
|
||||||
"min_initialized",
|
|
||||||
"max_disappeared",
|
|
||||||
"annotation_offset",
|
|
||||||
"stationary",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
||||||
|
|||||||
@ -32,18 +32,7 @@ const faceRecognition: SectionConfigOverrides = {
|
|||||||
"blur_confidence_filter",
|
"blur_confidence_filter",
|
||||||
"device",
|
"device",
|
||||||
],
|
],
|
||||||
restartRequired: [
|
restartRequired: ["enabled", "model_size", "device"],
|
||||||
"enabled",
|
|
||||||
"model_size",
|
|
||||||
"unknown_score",
|
|
||||||
"detection_threshold",
|
|
||||||
"recognition_threshold",
|
|
||||||
"min_area",
|
|
||||||
"min_faces",
|
|
||||||
"save_attempts",
|
|
||||||
"blur_confidence_filter",
|
|
||||||
"device",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -116,16 +116,7 @@ const ffmpeg: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: [
|
restartRequired: [],
|
||||||
"path",
|
|
||||||
"global_args",
|
|
||||||
"hwaccel_args",
|
|
||||||
"input_args",
|
|
||||||
"output_args",
|
|
||||||
"retry_interval",
|
|
||||||
"apple_compatibility",
|
|
||||||
"gpu",
|
|
||||||
],
|
|
||||||
fieldOrder: [
|
fieldOrder: [
|
||||||
"hwaccel_args",
|
"hwaccel_args",
|
||||||
"path",
|
"path",
|
||||||
@ -162,17 +153,7 @@ const ffmpeg: SectionConfigOverrides = {
|
|||||||
fieldGroups: {
|
fieldGroups: {
|
||||||
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
|
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
|
||||||
},
|
},
|
||||||
restartRequired: [
|
restartRequired: [],
|
||||||
"inputs",
|
|
||||||
"path",
|
|
||||||
"global_args",
|
|
||||||
"hwaccel_args",
|
|
||||||
"input_args",
|
|
||||||
"output_args",
|
|
||||||
"retry_interval",
|
|
||||||
"apple_compatibility",
|
|
||||||
"gpu",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -40,21 +40,7 @@ const lpr: SectionConfigOverrides = {
|
|||||||
"device",
|
"device",
|
||||||
"replace_rules",
|
"replace_rules",
|
||||||
],
|
],
|
||||||
restartRequired: [
|
restartRequired: ["model_size", "enhancement", "device"],
|
||||||
"enabled",
|
|
||||||
"model_size",
|
|
||||||
"detection_threshold",
|
|
||||||
"min_area",
|
|
||||||
"recognition_threshold",
|
|
||||||
"min_plate_length",
|
|
||||||
"format",
|
|
||||||
"match_distance",
|
|
||||||
"known_plates",
|
|
||||||
"enhancement",
|
|
||||||
"debug_save_plates",
|
|
||||||
"device",
|
|
||||||
"replace_rules",
|
|
||||||
],
|
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
format: {
|
format: {
|
||||||
"ui:options": { size: "md" },
|
"ui:options": { size: "md" },
|
||||||
|
|||||||
@ -31,18 +31,7 @@ const motion: SectionConfigOverrides = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: [
|
restartRequired: ["frame_height"],
|
||||||
"enabled",
|
|
||||||
"threshold",
|
|
||||||
"lightning_threshold",
|
|
||||||
"skip_motion_threshold",
|
|
||||||
"improve_contrast",
|
|
||||||
"contour_area",
|
|
||||||
"delta_alpha",
|
|
||||||
"frame_alpha",
|
|
||||||
"frame_height",
|
|
||||||
"mqtt_off_delay",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
restartRequired: ["frame_height"],
|
restartRequired: ["frame_height"],
|
||||||
|
|||||||
@ -83,7 +83,7 @@ const objects: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: ["track", "alert", "detect", "filters", "genai"],
|
restartRequired: [],
|
||||||
hiddenFields: [
|
hiddenFields: [
|
||||||
"enabled_in_config",
|
"enabled_in_config",
|
||||||
"mask",
|
"mask",
|
||||||
|
|||||||
@ -29,16 +29,7 @@ const record: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: [
|
restartRequired: [],
|
||||||
"enabled",
|
|
||||||
"expire_interval",
|
|
||||||
"continuous",
|
|
||||||
"motion",
|
|
||||||
"alerts",
|
|
||||||
"detections",
|
|
||||||
"preview",
|
|
||||||
"export",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
|
|||||||
@ -44,7 +44,7 @@ const review: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: ["alerts", "detections", "genai"],
|
restartRequired: [],
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
|
|||||||
@ -27,14 +27,7 @@ const snapshots: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: [
|
restartRequired: [],
|
||||||
"enabled",
|
|
||||||
"bounding_box",
|
|
||||||
"crop",
|
|
||||||
"quality",
|
|
||||||
"timestamp",
|
|
||||||
"retain",
|
|
||||||
],
|
|
||||||
hiddenFields: ["enabled_in_config", "required_zones"],
|
hiddenFields: ["enabled_in_config", "required_zones"],
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
|
|||||||
@ -3,14 +3,7 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const telemetry: SectionConfigOverrides = {
|
const telemetry: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/reference",
|
sectionDocs: "/configuration/reference",
|
||||||
restartRequired: [
|
restartRequired: ["version_check"],
|
||||||
"network_interfaces",
|
|
||||||
"stats.amd_gpu_stats",
|
|
||||||
"stats.intel_gpu_stats",
|
|
||||||
"stats.intel_gpu_device",
|
|
||||||
"stats.network_bandwidth",
|
|
||||||
"version_check",
|
|
||||||
],
|
|
||||||
fieldOrder: ["network_interfaces", "stats", "version_check"],
|
fieldOrder: ["network_interfaces", "stats", "version_check"],
|
||||||
advancedFields: [],
|
advancedFields: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -56,6 +56,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|||||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||||
import {
|
import {
|
||||||
cameraUpdateTopicMap,
|
cameraUpdateTopicMap,
|
||||||
|
globalCameraDefaultSections,
|
||||||
buildOverrides,
|
buildOverrides,
|
||||||
buildConfigDataForPath,
|
buildConfigDataForPath,
|
||||||
sanitizeSectionData as sharedSanitizeSectionData,
|
sanitizeSectionData as sharedSanitizeSectionData,
|
||||||
@ -234,7 +235,10 @@ export function ConfigSection({
|
|||||||
? cameraUpdateTopicMap[sectionPath]
|
? cameraUpdateTopicMap[sectionPath]
|
||||||
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
||||||
: undefined
|
: undefined
|
||||||
: `config/${sectionPath}`;
|
: globalCameraDefaultSections.has(sectionPath) &&
|
||||||
|
cameraUpdateTopicMap[sectionPath]
|
||||||
|
? `config/cameras/*/${cameraUpdateTopicMap[sectionPath]}`
|
||||||
|
: `config/${sectionPath}`;
|
||||||
// Default: show title for camera level (since it might be collapsible), hide for global
|
// Default: show title for camera level (since it might be collapsible), hide for global
|
||||||
const shouldShowTitle = showTitle ?? effectiveLevel === "camera";
|
const shouldShowTitle = showTitle ?? effectiveLevel === "camera";
|
||||||
|
|
||||||
@ -827,7 +831,7 @@ export function ConfigSection({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full border-t border-secondary bg-background pb-5 pt-0",
|
"w-full border-t border-secondary bg-background pt-0",
|
||||||
!noStickyButtons && "sticky bottom-0 z-50",
|
!noStickyButtons && "sticky bottom-0 z-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useContext, useEffect, useMemo } from "react";
|
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
|
||||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { usePersistence } from "./use-persistence";
|
import { usePersistence } from "./use-persistence";
|
||||||
import { useUserPersistence } from "./use-user-persistence";
|
import { useUserPersistence } from "./use-user-persistence";
|
||||||
@ -12,20 +12,28 @@ export function useOverlayState<S>(
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const currentLocationState = useMemo(() => location.state, [location]);
|
const locationRef = useRef(location);
|
||||||
|
locationRef.current = location;
|
||||||
|
|
||||||
const setOverlayStateValue = useCallback(
|
const setOverlayStateValue = useCallback(
|
||||||
(value: S, replace: boolean = false) => {
|
(value: S, replace: boolean = false) => {
|
||||||
const newLocationState = { ...currentLocationState };
|
const loc = locationRef.current;
|
||||||
|
const currentValue = loc.state?.[key] as S | undefined;
|
||||||
|
|
||||||
|
if (Object.is(currentValue, value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLocationState = { ...loc.state };
|
||||||
newLocationState[key] = value;
|
newLocationState[key] = value;
|
||||||
navigate(location.pathname + (preserveSearch ? location.search : ""), {
|
navigate(loc.pathname + (preserveSearch ? loc.search : ""), {
|
||||||
state: newLocationState,
|
state: newLocationState,
|
||||||
replace,
|
replace,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// we know that these deps are correct
|
// locationRef is stable so we don't need it in deps
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[key, currentLocationState, navigate],
|
[key, navigate, preserveSearch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const overlayStateValue = useMemo<S | undefined>(
|
const overlayStateValue = useMemo<S | undefined>(
|
||||||
@ -47,7 +55,9 @@ export function usePersistedOverlayState<S extends string>(
|
|||||||
] {
|
] {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const currentLocationState = useMemo(() => location.state, [location]);
|
|
||||||
|
const locationRef = useRef(location);
|
||||||
|
locationRef.current = location;
|
||||||
|
|
||||||
// currently selected value
|
// currently selected value
|
||||||
|
|
||||||
@ -63,14 +73,21 @@ export function usePersistedOverlayState<S extends string>(
|
|||||||
|
|
||||||
const setOverlayStateValue = useCallback(
|
const setOverlayStateValue = useCallback(
|
||||||
(value: S | undefined, replace: boolean = false) => {
|
(value: S | undefined, replace: boolean = false) => {
|
||||||
|
const loc = locationRef.current;
|
||||||
|
const currentValue = loc.state?.[key] as S | undefined;
|
||||||
|
|
||||||
|
if (Object.is(currentValue, value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setPersistedValue(value);
|
setPersistedValue(value);
|
||||||
const newLocationState = { ...currentLocationState };
|
const newLocationState = { ...loc.state };
|
||||||
newLocationState[key] = value;
|
newLocationState[key] = value;
|
||||||
navigate(location.pathname, { state: newLocationState, replace });
|
navigate(loc.pathname, { state: newLocationState, replace });
|
||||||
},
|
},
|
||||||
// we know that these deps are correct
|
// locationRef is stable so we don't need it in deps
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[key, currentLocationState, navigate],
|
[key, navigate, setPersistedValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -98,7 +115,9 @@ export function useUserPersistedOverlayState<S extends string>(
|
|||||||
const { auth } = useContext(AuthContext);
|
const { auth } = useContext(AuthContext);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const currentLocationState = useMemo(() => location.state, [location]);
|
|
||||||
|
const locationRef = useRef(location);
|
||||||
|
locationRef.current = location;
|
||||||
|
|
||||||
// currently selected value from URL state
|
// currently selected value from URL state
|
||||||
const overlayStateValue = useMemo<S | undefined>(
|
const overlayStateValue = useMemo<S | undefined>(
|
||||||
@ -112,14 +131,21 @@ export function useUserPersistedOverlayState<S extends string>(
|
|||||||
|
|
||||||
const setOverlayStateValue = useCallback(
|
const setOverlayStateValue = useCallback(
|
||||||
(value: S | undefined, replace: boolean = false) => {
|
(value: S | undefined, replace: boolean = false) => {
|
||||||
|
const loc = locationRef.current;
|
||||||
|
const currentValue = loc.state?.[key] as S | undefined;
|
||||||
|
|
||||||
|
if (Object.is(currentValue, value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setPersistedValue(value);
|
setPersistedValue(value);
|
||||||
const newLocationState = { ...currentLocationState };
|
const newLocationState = { ...loc.state };
|
||||||
newLocationState[key] = value;
|
newLocationState[key] = value;
|
||||||
navigate(location.pathname, { state: newLocationState, replace });
|
navigate(loc.pathname, { state: newLocationState, replace });
|
||||||
},
|
},
|
||||||
// we know that these deps are correct
|
// locationRef is stable so we don't need it in deps
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[key, currentLocationState, navigate, setPersistedValue],
|
[key, navigate, setPersistedValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Don't return a value until auth has finished loading
|
// Don't return a value until auth has finished loading
|
||||||
@ -142,17 +168,21 @@ export function useHashState<S extends string>(): [
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const locationRef = useRef(location);
|
||||||
|
locationRef.current = location;
|
||||||
|
|
||||||
const setHash = useCallback(
|
const setHash = useCallback(
|
||||||
(value: S | undefined) => {
|
(value: S | undefined) => {
|
||||||
|
const loc = locationRef.current;
|
||||||
if (!value) {
|
if (!value) {
|
||||||
navigate(location.pathname);
|
navigate(loc.pathname);
|
||||||
} else {
|
} else {
|
||||||
navigate(`${location.pathname}#${value}`, { state: location.state });
|
navigate(`${loc.pathname}#${value}`, { state: loc.state });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// we know that these deps are correct
|
// locationRef is stable so we don't need it in deps
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[location, navigate],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hash = useMemo(
|
const hash = useMemo(
|
||||||
|
|||||||
@ -479,14 +479,7 @@ const CAMERA_SELECT_BUTTON_PAGES = [
|
|||||||
"regionGrid",
|
"regionGrid",
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
|
const ALLOWED_VIEWS_FOR_VIEWER = ["profileSettings", "notifications"];
|
||||||
|
|
||||||
const LARGE_BOTTOM_MARGIN_PAGES = [
|
|
||||||
"masksAndZones",
|
|
||||||
"motionTuner",
|
|
||||||
"mediaSync",
|
|
||||||
"regionGrid",
|
|
||||||
];
|
|
||||||
|
|
||||||
// keys for camera sections
|
// keys for camera sections
|
||||||
const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
|
const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
|
||||||
@ -1362,9 +1355,9 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SidebarProvider>
|
<SidebarProvider className="relative h-full min-h-0 flex-1">
|
||||||
<Sidebar variant="inset" className="relative mb-8 pl-0 pt-0">
|
<Sidebar variant="inset" className="absolute h-full pl-0 pt-0">
|
||||||
<SidebarContent className="scrollbar-container mb-24 overflow-y-auto border-r-[1px] border-secondary bg-background py-2">
|
<SidebarContent className="scrollbar-container overflow-y-auto border-r-[1px] border-secondary bg-background py-2">
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{settingsGroups.map((group) => {
|
{settingsGroups.map((group) => {
|
||||||
const filteredItems = group.items.filter((item) =>
|
const filteredItems = group.items.filter((item) =>
|
||||||
@ -1452,8 +1445,7 @@ export default function Settings() {
|
|||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"scrollbar-container mb-16 flex-1 overflow-y-auto p-2 pr-0",
|
"scrollbar-container flex-1 overflow-y-auto pl-2 pr-0 pt-2",
|
||||||
LARGE_BOTTOM_MARGIN_PAGES.includes(pageToggle) && "mb-24",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@ -54,6 +54,20 @@ export const cameraUpdateTopicMap: Record<string, string> = {
|
|||||||
ui: "ui",
|
ui: "ui",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sections where global config serves as the default for per-camera config.
|
||||||
|
// Global updates to these sections are fanned out to all cameras via wildcard.
|
||||||
|
export const globalCameraDefaultSections = new Set([
|
||||||
|
"detect",
|
||||||
|
"objects",
|
||||||
|
"motion",
|
||||||
|
"record",
|
||||||
|
"snapshots",
|
||||||
|
"review",
|
||||||
|
"audio",
|
||||||
|
"notifications",
|
||||||
|
"ffmpeg",
|
||||||
|
]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// buildOverrides — pure recursive diff of current vs stored config & defaults
|
// buildOverrides — pure recursive diff of current vs stored config & defaults
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -476,6 +490,9 @@ export function prepareSectionSavePayload(opts: {
|
|||||||
if (level === "camera" && cameraName) {
|
if (level === "camera" && cameraName) {
|
||||||
const topic = cameraUpdateTopicMap[sectionPath];
|
const topic = cameraUpdateTopicMap[sectionPath];
|
||||||
updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined;
|
updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined;
|
||||||
|
} else if (globalCameraDefaultSections.has(sectionPath)) {
|
||||||
|
const topic = cameraUpdateTopicMap[sectionPath];
|
||||||
|
updateTopic = topic ? `config/cameras/*/${topic}` : `config/${sectionPath}`;
|
||||||
} else {
|
} else {
|
||||||
updateTopic = `config/${sectionPath}`;
|
updateTopic = `config/${sectionPath}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -632,9 +632,10 @@ export default function DraggableGridLayout({
|
|||||||
toggleStats={() => toggleStats(camera.name)}
|
toggleStats={() => toggleStats(camera.name)}
|
||||||
volumeState={volumeStates[camera.name]}
|
volumeState={volumeStates[camera.name]}
|
||||||
setVolumeState={(value) =>
|
setVolumeState={(value) =>
|
||||||
setVolumeStates({
|
setVolumeStates((prev) => ({
|
||||||
|
...prev,
|
||||||
[camera.name]: value,
|
[camera.name]: value,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
muteAll={muteAll}
|
muteAll={muteAll}
|
||||||
unmuteAll={unmuteAll}
|
unmuteAll={unmuteAll}
|
||||||
|
|||||||
@ -131,12 +131,10 @@ export default function MotionSearchView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Camera previews – defer until dialog is closed
|
// Camera previews – defer until dialog is closed
|
||||||
const allPreviews = useCameraPreviews(
|
const allPreviews = useCameraPreviews(timeRange, {
|
||||||
isSearchDialogOpen ? { after: 0, before: 0 } : timeRange,
|
camera: selectedCamera ?? undefined,
|
||||||
{
|
fetchPreviews: !isSearchDialogOpen,
|
||||||
camera: selectedCamera ?? undefined,
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ROI state
|
// ROI state
|
||||||
const [polygonPoints, setPolygonPoints] = useState<number[][]>([]);
|
const [polygonPoints, setPolygonPoints] = useState<number[][]>([]);
|
||||||
|
|||||||
@ -210,7 +210,7 @@ export default function UiSettingsView() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col md:pb-8">
|
<div className="flex size-full flex-col">
|
||||||
<Toaster position="top-center" closeButton={true} />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
<Heading as="h4" className="mb-3">
|
<Heading as="h4" className="mb-3">
|
||||||
{t("general.title")}
|
{t("general.title")}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user