mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-08 21:11:25 +03:00
Compare commits
2 Commits
5211590866
...
4ff7ab96dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ff7ab96dc | ||
|
|
d0f44de6bc |
@ -72,7 +72,7 @@ This does not affect using hardware for accelerating other tasks such as [semant
|
||||
|
||||
# Officially Supported Detectors
|
||||
|
||||
Frigate provides a number of builtin detector types. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
|
||||
Frigate provides a number of builtin detector types. By default, Frigate will use a single OpenVINO detector running on the CPU. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
|
||||
|
||||
## Edge TPU Detector
|
||||
|
||||
|
||||
@ -192,7 +192,7 @@ cameras:
|
||||
|
||||
### Step 4: Configure detectors
|
||||
|
||||
By default, Frigate will use a single CPU detector.
|
||||
By default, Frigate will use a single OpenVINO detector running on the CPU.
|
||||
|
||||
In many cases, the integrated graphics on Intel CPUs provides sufficient performance for typical Frigate setups. If you have an Intel processor, you can follow the configuration below.
|
||||
|
||||
|
||||
@ -499,6 +499,40 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
)
|
||||
|
||||
|
||||
def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None:
|
||||
"""Substitute incoming `*:*` masked credentials with the in-memory ones.
|
||||
|
||||
The /config response masks ffmpeg input credentials, so the settings UI
|
||||
sends the masked path back when sibling fields (e.g. hwaccel_args) are
|
||||
edited. Without this we'd write `rtsp://*:*@host` into YAML and lose
|
||||
the real credentials. Mutates `config_data` in place.
|
||||
"""
|
||||
cameras = config_data.get("cameras")
|
||||
if not isinstance(cameras, dict):
|
||||
return
|
||||
|
||||
for camera_name, camera_data in cameras.items():
|
||||
if not isinstance(camera_data, dict):
|
||||
continue
|
||||
inputs = camera_data.get("ffmpeg", {}).get("inputs")
|
||||
if not isinstance(inputs, list):
|
||||
continue
|
||||
existing = config.cameras.get(camera_name)
|
||||
if existing is None:
|
||||
continue
|
||||
existing_paths = [inp.path for inp in existing.ffmpeg.inputs]
|
||||
for index, input_obj in enumerate(inputs):
|
||||
if not isinstance(input_obj, dict):
|
||||
continue
|
||||
path = input_obj.get("path")
|
||||
if not isinstance(path, str):
|
||||
continue
|
||||
if ("://*:*@" in path or "user=*&password=*" in path) and index < len(
|
||||
existing_paths
|
||||
):
|
||||
input_obj["path"] = existing_paths[index]
|
||||
|
||||
|
||||
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
||||
"""Apply config changes in-memory only, without writing to YAML.
|
||||
|
||||
@ -509,6 +543,7 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
try:
|
||||
updates = {}
|
||||
if body.config_data:
|
||||
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
|
||||
@ -615,6 +650,9 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if query_string:
|
||||
updates = process_config_query_string(query_string)
|
||||
elif body.config_data:
|
||||
_restore_masked_camera_paths(
|
||||
body.config_data, request.app.frigate_config
|
||||
)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
|
||||
@ -76,7 +76,7 @@ class CameraConfig(FrigateBaseModel):
|
||||
# Options with global fallback
|
||||
audio: AudioConfig = Field(
|
||||
default_factory=AudioConfig,
|
||||
title="Audio events",
|
||||
title="Audio detection",
|
||||
description="Settings for audio-based event detection for this camera.",
|
||||
)
|
||||
audio_transcription: CameraAudioTranscriptionConfig = Field(
|
||||
|
||||
@ -41,8 +41,7 @@ class GenAIConfig(FrigateBaseModel):
|
||||
title="Model",
|
||||
description="The model to use from the provider for generating descriptions or summaries.",
|
||||
)
|
||||
provider: GenAIProviderEnum | None = Field(
|
||||
default=None,
|
||||
provider: GenAIProviderEnum = Field(
|
||||
title="Provider",
|
||||
description="The GenAI provider to use (for example: ollama, gemini, openai).",
|
||||
)
|
||||
|
||||
@ -121,7 +121,10 @@ class CameraConfigUpdateSubscriber:
|
||||
elif update_type == CameraConfigUpdateEnum.objects:
|
||||
config.objects = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.record:
|
||||
old_enabled_in_config = config.record.enabled_in_config
|
||||
config.record = updated_config
|
||||
if old_enabled_in_config != updated_config.enabled_in_config:
|
||||
config.recreate_ffmpeg_cmds()
|
||||
elif update_type == CameraConfigUpdateEnum.review:
|
||||
config.review = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.review_genai:
|
||||
|
||||
@ -26,6 +26,11 @@ class EnrichmentsDeviceEnum(str, Enum):
|
||||
CPU = "CPU"
|
||||
|
||||
|
||||
class ModelSizeEnum(str, Enum):
|
||||
small = "small"
|
||||
large = "large"
|
||||
|
||||
|
||||
class TriggerType(str, Enum):
|
||||
THUMBNAIL = "thumbnail"
|
||||
DESCRIPTION = "description"
|
||||
@ -53,13 +58,13 @@ class AudioTranscriptionConfig(FrigateBaseModel):
|
||||
title="Transcription language",
|
||||
description="Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.",
|
||||
)
|
||||
device: Optional[EnrichmentsDeviceEnum] = Field(
|
||||
device: EnrichmentsDeviceEnum = Field(
|
||||
default=EnrichmentsDeviceEnum.CPU,
|
||||
title="Transcription device",
|
||||
description="Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription.",
|
||||
)
|
||||
model_size: str = Field(
|
||||
default="small",
|
||||
model_size: ModelSizeEnum = Field(
|
||||
default=ModelSizeEnum.small,
|
||||
title="Model size",
|
||||
description="Model size to use for offline audio event transcription.",
|
||||
)
|
||||
@ -189,8 +194,8 @@ class SemanticSearchConfig(FrigateBaseModel):
|
||||
return v
|
||||
return v
|
||||
|
||||
model_size: str = Field(
|
||||
default="small",
|
||||
model_size: ModelSizeEnum = Field(
|
||||
default=ModelSizeEnum.small,
|
||||
title="Model size",
|
||||
description="Select model size; 'small' runs on CPU and 'large' typically requires GPU.",
|
||||
)
|
||||
@ -253,8 +258,8 @@ class FaceRecognitionConfig(FrigateBaseModel):
|
||||
title="Enable face recognition",
|
||||
description="Enable or disable face recognition for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
model_size: str = Field(
|
||||
default="small",
|
||||
model_size: ModelSizeEnum = Field(
|
||||
default=ModelSizeEnum.small,
|
||||
title="Model size",
|
||||
description="Model size to use for face embeddings (small/large); larger may require GPU.",
|
||||
)
|
||||
@ -335,8 +340,8 @@ class LicensePlateRecognitionConfig(FrigateBaseModel):
|
||||
title="Enable LPR",
|
||||
description="Enable or disable license plate recognition for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
model_size: str = Field(
|
||||
default="small",
|
||||
model_size: ModelSizeEnum = Field(
|
||||
default=ModelSizeEnum.small,
|
||||
title="Model size",
|
||||
description="Model size used for text detection/recognition. Most users should use 'small'.",
|
||||
)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@ -80,17 +81,40 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
yaml = YAML()
|
||||
|
||||
DEFAULT_DETECTORS = {
|
||||
"ov": {
|
||||
"type": "openvino",
|
||||
"device": "CPU",
|
||||
}
|
||||
}
|
||||
DEFAULT_MODEL = {
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"input_tensor": "nhwc",
|
||||
"input_pixel_format": "bgr",
|
||||
"path": "/openvino-model/ssdlite_mobilenet_v2.xml",
|
||||
"labelmap_path": "/openvino-model/coco_91cl_bkgr.txt",
|
||||
}
|
||||
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
|
||||
|
||||
|
||||
def _render_default_yaml(data: dict) -> str:
|
||||
buf = io.StringIO()
|
||||
_yaml_writer = YAML()
|
||||
_yaml_writer.indent(mapping=2, sequence=4, offset=2)
|
||||
_yaml_writer.dump(data, buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
DEFAULT_CONFIG = f"""
|
||||
mqtt:
|
||||
enabled: False
|
||||
|
||||
{_render_default_yaml({"detectors": DEFAULT_DETECTORS, "model": DEFAULT_MODEL})}
|
||||
cameras: {{}} # No cameras defined, UI wizard should be used
|
||||
version: {CURRENT_CONFIG_VERSION}
|
||||
"""
|
||||
|
||||
DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}}
|
||||
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
|
||||
|
||||
# stream info handler
|
||||
stream_info_retriever = StreamInfoRetriever()
|
||||
|
||||
@ -453,7 +477,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
cameras: Dict[str, CameraConfig] = Field(title="Cameras", description="Cameras")
|
||||
audio: AudioConfig = Field(
|
||||
default_factory=AudioConfig,
|
||||
title="Audio events",
|
||||
title="Audio detection",
|
||||
description="Settings for audio-based event detection for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
birdseye: BirdseyeConfig = Field(
|
||||
@ -679,6 +703,9 @@ class FrigateConfig(FrigateBaseModel):
|
||||
model_config["path"] = "/cpu_model.tflite"
|
||||
elif detector_config.type == "edgetpu":
|
||||
model_config["path"] = "/edgetpu_model.tflite"
|
||||
elif detector_config.type == "openvino":
|
||||
for default_key, default_value in DEFAULT_MODEL.items():
|
||||
model_config.setdefault(default_key, default_value)
|
||||
|
||||
model = ModelConfig.model_validate(model_config)
|
||||
model.check_and_load_plus_model(self.plus_api, detector_config.type)
|
||||
|
||||
@ -64,9 +64,9 @@ class TestConfig(unittest.TestCase):
|
||||
|
||||
def test_config_class(self):
|
||||
frigate_config = FrigateConfig(**self.minimal)
|
||||
assert "cpu" in frigate_config.detectors.keys()
|
||||
assert frigate_config.detectors["cpu"].type == DetectorTypeEnum.cpu
|
||||
assert frigate_config.detectors["cpu"].model.width == 320
|
||||
assert "ov" in frigate_config.detectors.keys()
|
||||
assert frigate_config.detectors["ov"].type == DetectorTypeEnum.openvino
|
||||
assert frigate_config.detectors["ov"].model.width == 300
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_detector_custom_model_path(self, mock_labels):
|
||||
@ -1005,6 +1005,7 @@ class TestConfig(unittest.TestCase):
|
||||
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"detectors": {"cpu": {"type": "cpu"}},
|
||||
"model": {"path": "plus://test"},
|
||||
"cameras": {
|
||||
"back": {
|
||||
|
||||
@ -773,7 +773,9 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
logger.debug(f"Camera {camera} disabled, skipping update")
|
||||
continue
|
||||
|
||||
camera_state = self.camera_states[camera]
|
||||
camera_state = self.camera_states.get(camera)
|
||||
if camera_state is None:
|
||||
continue
|
||||
|
||||
camera_state.update(
|
||||
frame_name, frame_time, current_tracked_objects, motion_boxes, regions
|
||||
|
||||
@ -330,7 +330,12 @@ class TrackedObject:
|
||||
if self.obj_data["position_changes"] != obj_data["position_changes"]:
|
||||
significant_change = True
|
||||
|
||||
if self.obj_data["attributes"] != obj_data["attributes"]:
|
||||
# disappearance of a per-frame attribute can be caused by detection
|
||||
# skipping the object on a frame (stationary objects on non-interval
|
||||
# frames), so only flag when a new attribute label appears
|
||||
prev_labels = {a["label"] for a in self.obj_data["attributes"]}
|
||||
curr_labels = {a["label"] for a in obj_data["attributes"]}
|
||||
if curr_labels - prev_labels:
|
||||
significant_change = True
|
||||
|
||||
# if the state changed between stationary and active
|
||||
|
||||
@ -438,34 +438,32 @@ def process_frames(
|
||||
else:
|
||||
object_tracker.update_frame_times(frame_name, frame_time)
|
||||
|
||||
# group the attribute detections based on what label they apply to
|
||||
attribute_detections: dict[str, list[TrackedObjectAttribute]] = {}
|
||||
for label, attribute_labels in attributes_map.items():
|
||||
attribute_detections[label] = [
|
||||
TrackedObjectAttribute(d)
|
||||
for d in consolidated_detections
|
||||
if d[0] in attribute_labels
|
||||
]
|
||||
|
||||
# build detections
|
||||
detections = {}
|
||||
for obj in object_tracker.tracked_objects.values():
|
||||
detections[obj["id"]] = {**obj, "attributes": []}
|
||||
|
||||
# find the best object for each attribute to be assigned to
|
||||
# assign each detected attribute to the best matching object.
|
||||
# iterate consolidated_detections once so attributes that appear under
|
||||
# multiple parent labels in attributes_map (e.g. license_plate is in
|
||||
# both "car" and "motorcycle") are not appended more than once
|
||||
all_objects: list[dict[str, Any]] = object_tracker.tracked_objects.values()
|
||||
for attributes in attribute_detections.values():
|
||||
for attribute in attributes:
|
||||
filtered_objects = filter(
|
||||
lambda o: attribute.label in attributes_map.get(o["label"], []),
|
||||
all_objects,
|
||||
)
|
||||
selected_object_id = attribute.find_best_object(filtered_objects)
|
||||
detected_attributes = [
|
||||
TrackedObjectAttribute(d)
|
||||
for d in consolidated_detections
|
||||
if d[0] in all_attributes
|
||||
]
|
||||
for attribute in detected_attributes:
|
||||
filtered_objects = filter(
|
||||
lambda o: attribute.label in attributes_map.get(o["label"], []),
|
||||
all_objects,
|
||||
)
|
||||
selected_object_id = attribute.find_best_object(filtered_objects)
|
||||
|
||||
if selected_object_id is not None:
|
||||
detections[selected_object_id]["attributes"].append(
|
||||
attribute.get_tracking_data()
|
||||
)
|
||||
if selected_object_id is not None:
|
||||
detections[selected_object_id]["attributes"].append(
|
||||
attribute.get_tracking_data()
|
||||
)
|
||||
|
||||
# debug object tracking
|
||||
if False:
|
||||
|
||||
@ -174,6 +174,7 @@ class CameraWatchdog(threading.Thread):
|
||||
)
|
||||
self.requestor = InterProcessRequestor()
|
||||
self.was_enabled = self.config.enabled
|
||||
self.was_record_enabled_in_config = self.config.record.enabled_in_config
|
||||
|
||||
self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all)
|
||||
self.latest_valid_segment_time: float = 0
|
||||
@ -323,6 +324,22 @@ class CameraWatchdog(threading.Thread):
|
||||
self.was_enabled = enabled
|
||||
continue
|
||||
|
||||
record_enabled_in_config = self.config.record.enabled_in_config
|
||||
if record_enabled_in_config != self.was_record_enabled_in_config:
|
||||
if record_enabled_in_config and enabled:
|
||||
self.logger.debug(
|
||||
f"Record enabled in config for {self.config.name}, restarting ffmpeg"
|
||||
)
|
||||
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()
|
||||
self.was_record_enabled_in_config = record_enabled_in_config
|
||||
continue
|
||||
|
||||
if not enabled:
|
||||
continue
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"description": "Enabled"
|
||||
},
|
||||
"audio": {
|
||||
"label": "Audio events",
|
||||
"label": "Audio detection",
|
||||
"description": "Settings for audio-based event detection for this camera.",
|
||||
"enabled": {
|
||||
"label": "Enable audio detection",
|
||||
|
||||
@ -539,7 +539,7 @@
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"label": "Audio events",
|
||||
"label": "Audio detection",
|
||||
"description": "Settings for audio-based event detection for all cameras; can be overridden per-camera.",
|
||||
"enabled": {
|
||||
"label": "Enable audio detection",
|
||||
|
||||
@ -70,7 +70,8 @@
|
||||
},
|
||||
"recording": {
|
||||
"enable": "Enable Recording",
|
||||
"disable": "Disable Recording"
|
||||
"disable": "Disable Recording",
|
||||
"disabledInConfig": "Recording must first be enabled in Settings for this camera."
|
||||
},
|
||||
"snapshots": {
|
||||
"enable": "Enable Snapshots",
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
"globalMotion": "Motion detection",
|
||||
"globalObjects": "Objects",
|
||||
"globalReview": "Review",
|
||||
"globalAudioEvents": "Audio events",
|
||||
"globalAudioEvents": "Audio detection",
|
||||
"globalLivePlayback": "Live playback",
|
||||
"globalTimestampStyle": "Timestamp style",
|
||||
"systemDatabase": "Database",
|
||||
@ -80,7 +80,7 @@
|
||||
"cameraMotion": "Motion detection",
|
||||
"cameraObjects": "Objects",
|
||||
"cameraConfigReview": "Review",
|
||||
"cameraAudioEvents": "Audio events",
|
||||
"cameraAudioEvents": "Audio detection",
|
||||
"cameraAudioTranscription": "Audio transcription",
|
||||
"cameraNotifications": "Notifications",
|
||||
"cameraLivePlayback": "Live playback",
|
||||
@ -1651,7 +1651,8 @@
|
||||
"review": {
|
||||
"recordDisabled": "Recording is disabled, review items will not be generated.",
|
||||
"detectDisabled": "Object detection is disabled. Review items require detected objects to categorize alerts and detections.",
|
||||
"allNonAlertDetections": "All non-alert activity will be included as detections."
|
||||
"allNonAlertDetections": "All non-alert activity will be included as detections.",
|
||||
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images."
|
||||
},
|
||||
"audio": {
|
||||
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
|
||||
@ -1660,15 +1661,21 @@
|
||||
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active."
|
||||
},
|
||||
"detect": {
|
||||
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit."
|
||||
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
|
||||
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function."
|
||||
},
|
||||
"objects": {
|
||||
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
|
||||
},
|
||||
"faceRecognition": {
|
||||
"globalDisabled": "Face recognition is not enabled at the global level. Enable it in Enrichments for camera-level face recognition to function.",
|
||||
"personNotTracked": "Face recognition requires the 'person' object to be tracked. Ensure 'person' is in the object tracking list."
|
||||
"globalDisabled": "The face recognition enrichment must be enabled for face recognition features to function on this camera.",
|
||||
"personNotTracked": "Face recognition requires the 'person' object to be tracked. Enable 'person' in Objects for this camera.",
|
||||
"modelSizeLarge": "The 'large' model requires a GPU or NPU for reasonable performance. Use 'small' on CPU-only systems."
|
||||
},
|
||||
"lpr": {
|
||||
"globalDisabled": "License plate recognition is not enabled at the global level. Enable it in Enrichments for camera-level LPR to function.",
|
||||
"vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked."
|
||||
"globalDisabled": "The license plate recognition enrichment must be enabled for LPR features to function on this camera.",
|
||||
"vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked. Enable 'car' or 'motorcycle' in Objects for this camera.",
|
||||
"modelSizeLarge": "The 'large' model is optimized for multi-line license plates. The 'small' model provides better performance over 'large' and should be used unless your region uses multi-line plate formats."
|
||||
},
|
||||
"record": {
|
||||
"noRecordRole": "No streams have the record role defined. Recording will not function."
|
||||
@ -1682,6 +1689,9 @@
|
||||
"detectors": {
|
||||
"mixedTypes": "All detectors must use the same type. Remove existing detectors to use a different type.",
|
||||
"mixedTypesSuggestion": "All detectors must use the same type. Remove existing detectors or select {{type}}."
|
||||
},
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -177,6 +177,9 @@
|
||||
}
|
||||
},
|
||||
"framesAndDetections": "Frames / Detections",
|
||||
"noCameras": {
|
||||
"title": "No Cameras Found"
|
||||
},
|
||||
"label": {
|
||||
"camera": "camera",
|
||||
"detect": "detect",
|
||||
|
||||
@ -56,7 +56,14 @@ export function WsProvider({ children }: { children: ReactNode }) {
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
}
|
||||
wsRef.current?.close();
|
||||
const ws = wsRef.current;
|
||||
if (ws) {
|
||||
ws.onopen = null;
|
||||
ws.onmessage = null;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
}
|
||||
resetWsStore();
|
||||
};
|
||||
}, [wsUrl]);
|
||||
|
||||
@ -10,7 +10,11 @@ const audioTranscription: SectionConfigOverrides = {
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
return ctx.fullCameraConfig.audio.enabled === false;
|
||||
return (
|
||||
!ctx.fullCameraConfig.ffmpeg?.inputs?.some((input) =>
|
||||
input.roles?.includes("audio"),
|
||||
) || ctx.fullCameraConfig.audio.enabled === false
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
@ -3,6 +3,15 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const detect: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/camera_specific",
|
||||
messages: [
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.detect.disabled",
|
||||
severity: "info",
|
||||
condition: (ctx) =>
|
||||
ctx.level === "camera" && ctx.formData?.enabled === false,
|
||||
},
|
||||
],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "fps-greater-than-five",
|
||||
|
||||
@ -53,6 +53,21 @@ const faceRecognition: SectionConfigOverrides = {
|
||||
"device",
|
||||
],
|
||||
restartRequired: ["enabled", "model_size", "device"],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "model-size-large",
|
||||
field: "model_size",
|
||||
messageKey: "configMessages.faceRecognition.modelSizeLarge",
|
||||
severity: "info",
|
||||
position: "after",
|
||||
condition: (ctx) => ctx.formData?.model_size === "large",
|
||||
},
|
||||
],
|
||||
uiSchema: {
|
||||
model_size: {
|
||||
"ui:options": { size: "xs" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -65,6 +65,16 @@ const lpr: SectionConfigOverrides = {
|
||||
"replace_rules",
|
||||
],
|
||||
restartRequired: ["model_size", "enhancement", "device"],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "model-size-large",
|
||||
field: "model_size",
|
||||
messageKey: "configMessages.lpr.modelSizeLarge",
|
||||
severity: "info",
|
||||
position: "after",
|
||||
condition: (ctx) => ctx.formData?.model_size === "large",
|
||||
},
|
||||
],
|
||||
uiSchema: {
|
||||
format: {
|
||||
"ui:options": { size: "md" },
|
||||
@ -83,6 +93,9 @@ const lpr: SectionConfigOverrides = {
|
||||
suppressDescription: true,
|
||||
},
|
||||
},
|
||||
model_size: {
|
||||
"ui:options": { size: "xs" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,8 +1,42 @@
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
// Attribute labels (face, license_plate, Frigate+ couriers like DHL/Amazon,
|
||||
// etc.) are populated into objects.filters by the backend even when the
|
||||
// model can't actually detect them. They aren't user-settable, so hide any
|
||||
// `filters.<attr>` patterns from forms and override comparisons.
|
||||
const hideAttributeFilters = (config: FrigateConfig): string[] =>
|
||||
(config.model?.all_attributes ?? []).map((attr) => `filters.${attr}`);
|
||||
|
||||
const objects: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/object_filters",
|
||||
messages: [
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.detect.disabled",
|
||||
severity: "info",
|
||||
condition: (ctx) =>
|
||||
ctx.level === "camera" &&
|
||||
ctx.fullCameraConfig?.detect?.enabled === false,
|
||||
},
|
||||
],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "genai-no-descriptions-provider",
|
||||
field: "genai.enabled",
|
||||
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
|
||||
severity: "warning",
|
||||
position: "before",
|
||||
condition: (ctx) => {
|
||||
const providers = ctx.fullConfig.genai;
|
||||
if (!providers || Object.keys(providers).length === 0) return true;
|
||||
return !Object.values(providers).some((agent) =>
|
||||
agent.roles?.includes("descriptions"),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldDocs: {
|
||||
"filters.min_area": "/configuration/object_filters#object-area",
|
||||
"filters.max_area": "/configuration/object_filters#object-area",
|
||||
@ -26,6 +60,7 @@ const objects: SectionConfigOverrides = {
|
||||
"filters.*.raw_mask",
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
hideAttributeFilters,
|
||||
],
|
||||
advancedFields: ["genai"],
|
||||
uiSchema: {
|
||||
@ -99,6 +134,7 @@ const objects: SectionConfigOverrides = {
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
"genai.required_zones",
|
||||
hideAttributeFilters,
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
@ -123,6 +159,7 @@ const objects: SectionConfigOverrides = {
|
||||
"filters.*.raw_mask",
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
hideAttributeFilters,
|
||||
],
|
||||
advancedFields: [],
|
||||
},
|
||||
|
||||
@ -41,6 +41,38 @@ const review: SectionConfigOverrides = {
|
||||
return !Array.isArray(labels) || labels.length === 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "genai-no-descriptions-provider",
|
||||
field: "genai.enabled",
|
||||
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
|
||||
severity: "warning",
|
||||
position: "before",
|
||||
condition: (ctx) => {
|
||||
const providers = ctx.fullConfig.genai;
|
||||
if (!providers || Object.keys(providers).length === 0) return true;
|
||||
return !Object.values(providers).some((agent) =>
|
||||
agent.roles?.includes("descriptions"),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "genai-image-source-recordings-record-disabled",
|
||||
field: "genai.image_source",
|
||||
messageKey:
|
||||
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
|
||||
severity: "warning",
|
||||
position: "after",
|
||||
condition: (ctx) => {
|
||||
const genai = ctx.formData?.genai as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (genai?.image_source !== "recordings") return false;
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
return ctx.fullCameraConfig.record?.enabled === false;
|
||||
}
|
||||
return ctx.fullConfig.record?.enabled === false;
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldDocs: {
|
||||
"alerts.labels": "/configuration/review/#alerts-and-detections",
|
||||
|
||||
@ -18,6 +18,18 @@ const semanticSearch: SectionConfigOverrides = {
|
||||
advancedFields: ["reindex", "device"],
|
||||
restartRequired: ["enabled", "model", "model_size", "device"],
|
||||
hiddenFields: ["reindex"],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "jinav2-small-model-size",
|
||||
field: "model_size",
|
||||
messageKey: "configMessages.semanticSearch.jinav2SmallModelSize",
|
||||
severity: "warning",
|
||||
position: "after",
|
||||
condition: (ctx) =>
|
||||
ctx.formData?.model === "jinav2" &&
|
||||
ctx.formData?.model_size === "small",
|
||||
},
|
||||
],
|
||||
uiSchema: {
|
||||
model: {
|
||||
"ui:widget": "semanticSearchModel",
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
useRef,
|
||||
useContext,
|
||||
} from "react";
|
||||
import useSWR from "swr";
|
||||
import useSWR, { mutate as swrMutate } from "swr";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -22,6 +22,7 @@ import {
|
||||
modifySchemaForSection,
|
||||
getEffectiveDefaultsForSection,
|
||||
sanitizeOverridesForSection,
|
||||
synthesizeMissingObjectFilters,
|
||||
} from "./section-special-cases";
|
||||
import { getSectionValidation } from "../section-validations";
|
||||
import { useConfigOverride } from "@/hooks/use-config-override";
|
||||
@ -58,7 +59,11 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConfigSectionData, JsonValue } from "@/types/configForm";
|
||||
import {
|
||||
ConfigSectionData,
|
||||
HiddenFieldEntry,
|
||||
JsonValue,
|
||||
} from "@/types/configForm";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import {
|
||||
@ -68,6 +73,7 @@ import {
|
||||
buildConfigDataForPath,
|
||||
flattenOverrides,
|
||||
getBaseCameraSectionValue,
|
||||
resolveHiddenFieldEntries,
|
||||
sanitizeSectionData as sharedSanitizeSectionData,
|
||||
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
|
||||
} from "@/utils/configUtil";
|
||||
@ -90,7 +96,7 @@ export interface SectionConfig {
|
||||
/** Fields to group together */
|
||||
fieldGroups?: Record<string, string[]>;
|
||||
/** Fields to hide from UI */
|
||||
hiddenFields?: string[];
|
||||
hiddenFields?: HiddenFieldEntry[];
|
||||
/** Fields to show in advanced section */
|
||||
advancedFields?: string[];
|
||||
/** Fields to compare for override detection */
|
||||
@ -357,25 +363,34 @@ export function ConfigSection({
|
||||
return get(config, sectionPath);
|
||||
}, [config, cameraName, sectionPath, effectiveLevel, profileName]);
|
||||
|
||||
const rawFormData = useMemo(() => {
|
||||
const rawFormData = useMemo<ConfigSectionData>(() => {
|
||||
if (!config) return {};
|
||||
|
||||
if (rawSectionValue === undefined || rawSectionValue === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return rawSectionValue;
|
||||
}, [config, rawSectionValue]);
|
||||
return synthesizeMissingObjectFilters(
|
||||
sectionPath,
|
||||
rawSectionValue,
|
||||
modifiedSchema ?? undefined,
|
||||
) as ConfigSectionData;
|
||||
}, [config, rawSectionValue, sectionPath, modifiedSchema]);
|
||||
|
||||
// When editing a profile, hide fields that require a restart since they
|
||||
// cannot take effect via profile switching alone.
|
||||
const effectiveHiddenFields = useMemo(() => {
|
||||
const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, config);
|
||||
if (!profileName || !sectionConfig.restartRequired?.length) {
|
||||
return sectionConfig.hiddenFields;
|
||||
return base;
|
||||
}
|
||||
const base = sectionConfig.hiddenFields ?? [];
|
||||
return [...new Set([...base, ...sectionConfig.restartRequired])];
|
||||
}, [profileName, sectionConfig.hiddenFields, sectionConfig.restartRequired]);
|
||||
}, [
|
||||
profileName,
|
||||
sectionConfig.hiddenFields,
|
||||
sectionConfig.restartRequired,
|
||||
config,
|
||||
]);
|
||||
|
||||
const sanitizeSectionData = useCallback(
|
||||
(data: ConfigSectionData) =>
|
||||
@ -387,7 +402,7 @@ export function ConfigSection({
|
||||
const baseData = modifiedSchema
|
||||
? applySchemaDefaults(modifiedSchema, rawFormData)
|
||||
: rawFormData;
|
||||
return sanitizeSectionData(baseData);
|
||||
return sanitizeSectionData(baseData as ConfigSectionData);
|
||||
}, [rawFormData, modifiedSchema, sanitizeSectionData]);
|
||||
|
||||
const baselineSnapshot = useMemo(() => {
|
||||
@ -743,6 +758,7 @@ export function ConfigSection({
|
||||
}
|
||||
|
||||
await refreshConfig();
|
||||
swrMutate("config/raw_paths");
|
||||
setPendingData(null);
|
||||
onSave?.();
|
||||
} catch (error) {
|
||||
|
||||
@ -20,7 +20,7 @@ import type { ProfilesApiResponse } from "@/types/profile";
|
||||
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { formatList } from "@/utils/stringUtil";
|
||||
import { getSectionConfig } from "@/utils/configUtil";
|
||||
import { getEffectiveHiddenFields } from "@/utils/configUtil";
|
||||
|
||||
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
|
||||
detect: "cameraDetect",
|
||||
@ -43,17 +43,33 @@ const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
|
||||
const MAX_FIELDS_PER_CAMERA = 5;
|
||||
|
||||
/**
|
||||
* Enrichment sections where the cross-camera override badge should be
|
||||
* suppressed because they're effectively global-only (or per-camera
|
||||
* configuration there isn't a useful affordance to surface here).
|
||||
* Face recognition and LPR are intentionally omitted so the badge does show
|
||||
* on those enrichment pages.
|
||||
* Sections where the cross-camera override badge should be suppressed.
|
||||
* Includes enrichment sections that aren't meaningfully per-camera
|
||||
* (face recognition and LPR are intentionally omitted so the badge does show
|
||||
* there) and every System sub-page (detector hardware, database, networking,
|
||||
* etc.) which configures Frigate as a whole, not per-camera state.
|
||||
*/
|
||||
const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
|
||||
// Enrichments (face_recognition and lpr remain enabled)
|
||||
"semantic_search",
|
||||
"genai",
|
||||
"classification",
|
||||
"audio_transcription",
|
||||
// System
|
||||
"go2rtc_streams",
|
||||
"database",
|
||||
"mqtt",
|
||||
"tls",
|
||||
"auth",
|
||||
"networking",
|
||||
"proxy",
|
||||
"ui",
|
||||
"logger",
|
||||
"environment_vars",
|
||||
"telemetry",
|
||||
"birdseye",
|
||||
"detectors",
|
||||
"model",
|
||||
]);
|
||||
|
||||
/**
|
||||
@ -231,8 +247,11 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
|
||||
const rawEntries = useCamerasOverridingSection(config, sectionPath);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const hiddenFields =
|
||||
getSectionConfig(sectionPath, "global").hiddenFields ?? [];
|
||||
const hiddenFields = getEffectiveHiddenFields(
|
||||
sectionPath,
|
||||
"global",
|
||||
config,
|
||||
);
|
||||
if (hiddenFields.length === 0) return rawEntries;
|
||||
return rawEntries
|
||||
.map((entry) => ({
|
||||
@ -245,7 +264,7 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
|
||||
),
|
||||
}))
|
||||
.filter((entry) => entry.fieldDeltas.length > 0);
|
||||
}, [rawEntries, sectionPath]);
|
||||
}, [rawEntries, sectionPath, config]);
|
||||
|
||||
if (SECTIONS_WITHOUT_OVERRIDE_BADGE.has(sectionPath)) {
|
||||
return null;
|
||||
|
||||
@ -15,7 +15,7 @@ import { JsonObject, JsonValue } from "@/types/configForm";
|
||||
* Sections that require special handling at the global level.
|
||||
* Add new section paths here as needed.
|
||||
*/
|
||||
const SPECIAL_CASE_SECTIONS = ["motion", "detectors"] as const;
|
||||
const SPECIAL_CASE_SECTIONS = ["motion", "detectors", "genai"] as const;
|
||||
|
||||
/**
|
||||
* Check if a section requires special case handling.
|
||||
@ -53,6 +53,29 @@ export function modifySchemaForSection(
|
||||
return schemaWithoutDefault;
|
||||
}
|
||||
|
||||
if (sectionPath === "genai") {
|
||||
const additional = schema.additionalProperties;
|
||||
if (
|
||||
additional &&
|
||||
typeof additional === "object" &&
|
||||
!Array.isArray(additional)
|
||||
) {
|
||||
const props = (additional as RJSFSchema).properties;
|
||||
if (props && typeof props.provider === "object") {
|
||||
return {
|
||||
...schema,
|
||||
additionalProperties: {
|
||||
...additional,
|
||||
properties: {
|
||||
...props,
|
||||
provider: { ...(props.provider as object), default: "openai" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
@ -105,6 +128,51 @@ export function getEffectiveDefaultsForSection(
|
||||
return schemaDefaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add default filter entries for any label in `objects.track` that isn't
|
||||
* already in `objects.filters`, so each tracked label gets a collapsible.
|
||||
* The backend only auto-populates filters at config init, not after profile
|
||||
* merges.
|
||||
*/
|
||||
export function synthesizeMissingObjectFilters(
|
||||
sectionPath: string,
|
||||
data: unknown,
|
||||
sectionSchema: RJSFSchema | undefined,
|
||||
): unknown {
|
||||
if (sectionPath !== "objects") return data;
|
||||
if (!isJsonObject(data)) return data;
|
||||
|
||||
const trackValue = (data as JsonObject).track;
|
||||
if (!Array.isArray(trackValue) || trackValue.length === 0) return data;
|
||||
|
||||
const properties = (sectionSchema as { properties?: Record<string, unknown> })
|
||||
?.properties;
|
||||
const filtersSchema = isJsonObject(properties)
|
||||
? (properties.filters as { additionalProperties?: unknown } | undefined)
|
||||
: undefined;
|
||||
const filterEntrySchema = isJsonObject(filtersSchema?.additionalProperties)
|
||||
? (filtersSchema.additionalProperties as RJSFSchema)
|
||||
: undefined;
|
||||
|
||||
const existingFilters = isJsonObject((data as JsonObject).filters)
|
||||
? ((data as JsonObject).filters as JsonObject)
|
||||
: {};
|
||||
|
||||
const newFilters: JsonObject = { ...existingFilters };
|
||||
let added = false;
|
||||
for (const label of trackValue) {
|
||||
if (typeof label !== "string") continue;
|
||||
if (Object.prototype.hasOwnProperty.call(newFilters, label)) continue;
|
||||
newFilters[label] = (
|
||||
filterEntrySchema ? applySchemaDefaults(filterEntrySchema, {}) : {}
|
||||
) as JsonValue;
|
||||
added = true;
|
||||
}
|
||||
|
||||
if (!added) return data;
|
||||
return { ...(data as JsonObject), filters: newFilters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize overrides payloads for section-specific quirks.
|
||||
*/
|
||||
|
||||
@ -1,12 +1,21 @@
|
||||
// Hook to detect when camera config overrides global defaults
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import get from "lodash/get";
|
||||
import set from "lodash/set";
|
||||
import type { RJSFSchema } from "@rjsf/utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { JsonObject, JsonValue } from "@/types/configForm";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { getBaseCameraSectionValue } from "@/utils/configUtil";
|
||||
import {
|
||||
getBaseCameraSectionValue,
|
||||
getEffectiveHiddenFields,
|
||||
unsetWithWildcard,
|
||||
} from "@/utils/configUtil";
|
||||
import { extractSectionSchema } from "@/hooks/use-config-schema";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
|
||||
const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"];
|
||||
|
||||
@ -34,6 +43,51 @@ export function normalizeConfigValue(value: unknown): JsonValue {
|
||||
return stripInternalFields(value as JsonValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove hidden-field paths from a value before comparison so fields the
|
||||
* user can't change in the UI (e.g. motion masks, attribute filters) don't
|
||||
* trigger override badges. Operates on a clone so the input is unchanged.
|
||||
*/
|
||||
function stripHiddenPaths(value: JsonValue, hiddenFields: string[]): JsonValue {
|
||||
if (hiddenFields.length === 0 || !isJsonObject(value)) return value;
|
||||
const cloned = cloneDeep(value) as JsonObject;
|
||||
for (const path of hiddenFields) {
|
||||
if (!path) continue;
|
||||
unsetWithWildcard(cloned as Record<string, unknown>, path);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse null and empty-object values for override comparisons so
|
||||
* semantically equivalent shapes match. The schema may default `mask: None`
|
||||
* while the runtime camera config carries `mask: {}` — both mean "no
|
||||
* masks", so collapsing them here keeps the equality check honest. We
|
||||
* keep this off the public `normalizeConfigValue` so save-flow code paths
|
||||
* (which serialize form data) aren't affected.
|
||||
**/
|
||||
function collapseEmpty(value: JsonValue): JsonValue {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(collapseEmpty);
|
||||
}
|
||||
if (isJsonObject(value)) {
|
||||
const cleaned: JsonObject = {};
|
||||
for (const [key, val] of Object.entries(value as JsonObject)) {
|
||||
if (val === null || val === undefined) continue;
|
||||
const collapsed = collapseEmpty(val as JsonValue);
|
||||
if (
|
||||
isJsonObject(collapsed) &&
|
||||
Object.keys(collapsed as JsonObject).length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
cleaned[key] = collapsed;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface OverrideStatus {
|
||||
/** Whether the field is overridden from global */
|
||||
isOverridden: boolean;
|
||||
@ -96,6 +150,7 @@ export function useConfigOverride({
|
||||
sectionPath,
|
||||
compareFields,
|
||||
}: UseConfigOverrideOptions) {
|
||||
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
|
||||
return useMemo(() => {
|
||||
if (!config) {
|
||||
return {
|
||||
@ -153,15 +208,42 @@ export function useConfigOverride({
|
||||
sectionPath,
|
||||
);
|
||||
|
||||
const normalizedGlobalValue = normalizeConfigValue(globalValue);
|
||||
// Use the effective baseline (schema defaults when the global section
|
||||
// is unset, e.g. motion). Without this, sections omitted from the global
|
||||
// YAML would always read as "overridden" because the raw global value is
|
||||
// null while every camera has populated defaults.
|
||||
const normalizedGlobalValue = getEffectiveGlobalBaseline(
|
||||
config,
|
||||
sectionPath,
|
||||
compareFields,
|
||||
schema,
|
||||
);
|
||||
const normalizedCameraValue = normalizeConfigValue(cameraValue);
|
||||
|
||||
// Collapse empty/null values for comparison so semantically equivalent
|
||||
// shapes (e.g. schema default `mask: null` vs runtime `mask: {}`) match.
|
||||
// Also strip hidden-field paths (motion masks, attribute filters, etc.)
|
||||
// so fields the user can't edit in the UI don't trigger override badges.
|
||||
const hiddenFields = getEffectiveHiddenFields(
|
||||
sectionPath,
|
||||
"camera",
|
||||
config,
|
||||
);
|
||||
const collapsedGlobal = stripHiddenPaths(
|
||||
collapseEmpty(normalizedGlobalValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const collapsedCamera = stripHiddenPaths(
|
||||
collapseEmpty(normalizedCameraValue),
|
||||
hiddenFields,
|
||||
);
|
||||
|
||||
const comparisonGlobal = compareFields
|
||||
? pickFields(normalizedGlobalValue, compareFields)
|
||||
: normalizedGlobalValue;
|
||||
? pickFields(collapsedGlobal, compareFields)
|
||||
: collapsedGlobal;
|
||||
const comparisonCamera = compareFields
|
||||
? pickFields(normalizedCameraValue, compareFields)
|
||||
: normalizedCameraValue;
|
||||
? pickFields(collapsedCamera, compareFields)
|
||||
: collapsedCamera;
|
||||
|
||||
// Check if the entire section is overridden
|
||||
const isOverridden = compareFields
|
||||
@ -176,7 +258,10 @@ export function useConfigOverride({
|
||||
const cameraFieldValue = get(normalizedCameraValue, fieldPath);
|
||||
|
||||
return {
|
||||
isOverridden: !isEqual(globalFieldValue, cameraFieldValue),
|
||||
isOverridden: !isEqual(
|
||||
collapseEmpty(globalFieldValue as JsonValue),
|
||||
collapseEmpty(cameraFieldValue as JsonValue),
|
||||
),
|
||||
globalValue: globalFieldValue,
|
||||
cameraValue: cameraFieldValue,
|
||||
};
|
||||
@ -199,7 +284,7 @@ export function useConfigOverride({
|
||||
getFieldOverride,
|
||||
resetToGlobal,
|
||||
};
|
||||
}, [config, cameraName, sectionPath, compareFields]);
|
||||
}, [config, cameraName, sectionPath, compareFields, schema]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -252,6 +337,7 @@ export function useAllCameraOverrides(
|
||||
config: FrigateConfig | undefined,
|
||||
cameraName: string | undefined,
|
||||
) {
|
||||
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
|
||||
return useMemo(() => {
|
||||
if (!config || !cameraName) {
|
||||
return [];
|
||||
@ -265,17 +351,31 @@ export function useAllCameraOverrides(
|
||||
const overriddenSections: string[] = [];
|
||||
|
||||
for (const { key, compareFields } of OVERRIDABLE_SECTIONS) {
|
||||
const globalValue = normalizeConfigValue(get(config, key));
|
||||
const globalValue = getEffectiveGlobalBaseline(
|
||||
config,
|
||||
key,
|
||||
compareFields,
|
||||
schema,
|
||||
);
|
||||
const cameraValue = normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, cameraName, key),
|
||||
);
|
||||
|
||||
const hiddenFields = getEffectiveHiddenFields(key, "camera", config);
|
||||
const collapsedGlobal = stripHiddenPaths(
|
||||
collapseEmpty(globalValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const collapsedCamera = stripHiddenPaths(
|
||||
collapseEmpty(cameraValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const comparisonGlobal = compareFields
|
||||
? pickFields(globalValue, compareFields)
|
||||
: globalValue;
|
||||
? pickFields(collapsedGlobal, compareFields)
|
||||
: collapsedGlobal;
|
||||
const comparisonCamera = compareFields
|
||||
? pickFields(cameraValue, compareFields)
|
||||
: cameraValue;
|
||||
? pickFields(collapsedCamera, compareFields)
|
||||
: collapsedCamera;
|
||||
|
||||
if (
|
||||
compareFields && compareFields.length === 0
|
||||
@ -287,7 +387,7 @@ export function useAllCameraOverrides(
|
||||
}
|
||||
|
||||
return overriddenSections;
|
||||
}, [config, cameraName]);
|
||||
}, [config, cameraName, schema]);
|
||||
}
|
||||
|
||||
export interface FieldDelta {
|
||||
@ -386,14 +486,40 @@ function isPathAllowed(path: string, compareFields?: string[]): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Some Frigate sections (notably `motion`) are dumped by the backend with
|
||||
* `exclude_unset=True`, so when the user hasn't explicitly written the section
|
||||
* in their global YAML the API returns null even though every camera still
|
||||
* gets defaults applied at runtime. To still detect cross-camera differences
|
||||
* in those sections we synthesize a baseline by taking the modal (most common)
|
||||
* value at each leaf path across cameras — cameras whose value diverges from
|
||||
* the modal are treated as overriding.
|
||||
* Resolve the effective global baseline used for override comparisons.
|
||||
*
|
||||
* - When the global section is explicitly set, return it (normalized).
|
||||
* - Otherwise prefer the camera-level schema defaults so a camera that
|
||||
* diverges from the implicit Pydantic default registers as overriding
|
||||
* even with a single camera in the deployment. (Sections like `motion`
|
||||
* are dumped with `exclude_unset=True`, so the API returns null whenever
|
||||
* the user hasn't written the section globally.)
|
||||
* - Fall back to a modal-across-cameras synthetic baseline when the schema
|
||||
* hasn't loaded yet or the section isn't in it.
|
||||
*/
|
||||
function getEffectiveGlobalBaseline(
|
||||
config: FrigateConfig,
|
||||
sectionPath: string,
|
||||
compareFields?: string[],
|
||||
schema?: RJSFSchema,
|
||||
): JsonValue {
|
||||
const rawGlobalValue = get(config, sectionPath);
|
||||
if (rawGlobalValue != null) {
|
||||
return normalizeConfigValue(rawGlobalValue);
|
||||
}
|
||||
if (schema) {
|
||||
const sectionSchema = extractSectionSchema(schema, sectionPath, "camera");
|
||||
if (sectionSchema) {
|
||||
const defaults = applySchemaDefaults(sectionSchema, {});
|
||||
return normalizeConfigValue(defaults as JsonValue);
|
||||
}
|
||||
}
|
||||
const cameraSectionValues = Object.keys(config.cameras ?? {}).map((name) =>
|
||||
normalizeConfigValue(getBaseCameraSectionValue(config, name, sectionPath)),
|
||||
);
|
||||
return deriveSyntheticGlobalValue(cameraSectionValues, compareFields);
|
||||
}
|
||||
|
||||
function deriveSyntheticGlobalValue(
|
||||
cameraSectionValues: JsonValue[],
|
||||
compareFields?: string[],
|
||||
@ -461,6 +587,7 @@ export function useCamerasOverridingSection(
|
||||
config: FrigateConfig | undefined,
|
||||
sectionPath: string,
|
||||
): CameraOverrideEntry[] {
|
||||
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
|
||||
return useMemo(() => {
|
||||
if (!config?.cameras || !sectionPath) {
|
||||
return [];
|
||||
@ -476,11 +603,9 @@ export function useCamerasOverridingSection(
|
||||
),
|
||||
);
|
||||
|
||||
const rawGlobalValue = get(config, sectionPath);
|
||||
const globalValue: JsonValue =
|
||||
rawGlobalValue == null
|
||||
? deriveSyntheticGlobalValue(cameraSectionValues, compareFields)
|
||||
: normalizeConfigValue(rawGlobalValue);
|
||||
const globalValue = collapseEmpty(
|
||||
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
|
||||
);
|
||||
|
||||
const entries: CameraOverrideEntry[] = [];
|
||||
for (let idx = 0; idx < cameraNames.length; idx += 1) {
|
||||
@ -489,7 +614,7 @@ export function useCamerasOverridingSection(
|
||||
const deltasByPath = new Map<string, FieldDelta>();
|
||||
|
||||
// 1. Camera-level overrides (uses base_config when a profile is active)
|
||||
const cameraValue = cameraSectionValues[idx];
|
||||
const cameraValue = collapseEmpty(cameraSectionValues[idx]);
|
||||
for (const delta of collectFieldDeltas(
|
||||
globalValue,
|
||||
cameraValue,
|
||||
@ -536,5 +661,5 @@ export function useCamerasOverridingSection(
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [config, sectionPath]);
|
||||
}, [config, sectionPath, schema]);
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ const getSchemaDefinitions = (schema: RJSFSchema): Record<string, RJSFSchema> =>
|
||||
* Extracts and resolves a section schema from the full config schema
|
||||
* Uses caching to avoid repeated expensive resolution
|
||||
*/
|
||||
function extractSectionSchema(
|
||||
export function extractSectionSchema(
|
||||
schema: RJSFSchema,
|
||||
sectionPath: string,
|
||||
level: "global" | "camera",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
type OptimisticStateResult<T> = [T, (newValue: T) => void];
|
||||
|
||||
@ -8,37 +8,32 @@ const useOptimisticState = <T>(
|
||||
delay: number = 20,
|
||||
): OptimisticStateResult<T> => {
|
||||
const [optimisticValue, setOptimisticValue] = useState<T>(currentState);
|
||||
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(newValue: T) => {
|
||||
// Update the optimistic value immediately
|
||||
setOptimisticValue(newValue);
|
||||
|
||||
// Clear any pending debounce timeout
|
||||
if (debounceTimeout.current) {
|
||||
clearTimeout(debounceTimeout.current);
|
||||
}
|
||||
|
||||
// Set a new debounce timeout
|
||||
debounceTimeout.current = setTimeout(() => {
|
||||
// Update the actual value using the provided setter function
|
||||
setState(newValue);
|
||||
}, delay);
|
||||
},
|
||||
[delay, setState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimeout.current) {
|
||||
clearTimeout(debounceTimeout.current);
|
||||
}
|
||||
};
|
||||
const handleValueChange = useCallback((newValue: T) => {
|
||||
// Update the optimistic value immediately
|
||||
setOptimisticValue(newValue);
|
||||
}, []);
|
||||
|
||||
// Push the optimistic value to the real setter after the delay. Scoping
|
||||
// this to an effect keyed on optimisticValue ensures the cleanup only
|
||||
// cancels the timer for the value it scheduled — so StrictMode's
|
||||
// effect-rerun (and future re-running mechanisms) reschedules cleanly
|
||||
// instead of dropping the pending update on the floor.
|
||||
useEffect(() => {
|
||||
if (currentState != optimisticValue) {
|
||||
if (Object.is(optimisticValue, currentState)) {
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => setState(optimisticValue), delay);
|
||||
return () => clearTimeout(id);
|
||||
}, [optimisticValue, currentState, delay, setState]);
|
||||
|
||||
// External updates to currentState should win over a stale optimistic value.
|
||||
// The guard matters under StrictMode: this effect's re-run captures the
|
||||
// *old* currentState in its closure, so without the equality check it
|
||||
// would clobber an optimistic update that another effect (e.g. a search
|
||||
// param sync) made earlier in the same commit.
|
||||
useEffect(() => {
|
||||
if (!Object.is(currentState, optimisticValue)) {
|
||||
setOptimisticValue(currentState);
|
||||
}
|
||||
// sometimes an external action will cause the currentState to change
|
||||
|
||||
@ -89,7 +89,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
}
|
||||
|
||||
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
|
||||
if (config.cameras[name].enabled && cam["camera_fps"] == 0) {
|
||||
if (config.cameras?.[name]?.enabled && cam["camera_fps"] == 0) {
|
||||
problems.push({
|
||||
text: t("stats.cameraIsOffline", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
|
||||
@ -885,6 +885,7 @@ export default function Settings() {
|
||||
|
||||
// Refresh config from server once
|
||||
await mutate("config");
|
||||
mutate("config/raw_paths");
|
||||
|
||||
// Clear hasChanges in sidebar for all successfully saved sections
|
||||
if (savedKeys.length > 0) {
|
||||
|
||||
@ -13,6 +13,8 @@ export type JsonArray = JsonValue[];
|
||||
|
||||
export type ConfigSectionData = JsonObject;
|
||||
|
||||
export type HiddenFieldEntry = string | ((config: FrigateConfig) => string[]);
|
||||
|
||||
export type ConfigFormContext = {
|
||||
level?: "global" | "camera";
|
||||
cameraName?: string;
|
||||
|
||||
@ -254,7 +254,10 @@ export function flattenOverrides(
|
||||
|
||||
// lodash `unset` treats `*` as a literal key. This helper expands wildcard
|
||||
// segments so that e.g. `"filters.*.mask"` unsets `filters.<each key>.mask`.
|
||||
function unsetWithWildcard(obj: Record<string, unknown>, path: string): void {
|
||||
export function unsetWithWildcard(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
): void {
|
||||
if (!path.includes("*")) {
|
||||
unset(obj, path);
|
||||
return;
|
||||
@ -587,13 +590,14 @@ export function prepareSectionSavePayload(opts: {
|
||||
// For profile sections, also hide restart-required fields to match
|
||||
// effectiveHiddenFields in BaseSection (prevents spurious deletion markers
|
||||
// for fields that are hidden from the form during profile editing).
|
||||
let hiddenFieldsForSanitize = sectionConfig.hiddenFields;
|
||||
if (profileInfo.isProfile && sectionConfig.restartRequired?.length) {
|
||||
const base = sectionConfig.hiddenFields ?? [];
|
||||
hiddenFieldsForSanitize = [
|
||||
...new Set([...base, ...sectionConfig.restartRequired]),
|
||||
];
|
||||
}
|
||||
const resolvedHidden = resolveHiddenFieldEntries(
|
||||
sectionConfig.hiddenFields,
|
||||
config,
|
||||
);
|
||||
const hiddenFieldsForSanitize =
|
||||
profileInfo.isProfile && sectionConfig.restartRequired?.length
|
||||
? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])]
|
||||
: resolvedHidden;
|
||||
|
||||
// Sanitize raw form data
|
||||
const rawData = sanitizeSectionData(
|
||||
@ -702,3 +706,36 @@ export function getSectionConfig(
|
||||
: entry.camera;
|
||||
return mergeSectionConfig(entry.base, overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective hidden-field patterns for a section. Each entry in
|
||||
* `hiddenFields` is either a literal pattern or a function that produces
|
||||
* patterns from the loaded config (e.g. `filters.<attr>` for each
|
||||
* `model.all_attributes` entry on the objects section).
|
||||
*/
|
||||
export function getEffectiveHiddenFields(
|
||||
sectionKey: string,
|
||||
level: "global" | "camera" | "replay",
|
||||
config: FrigateConfig | undefined,
|
||||
): string[] {
|
||||
return resolveHiddenFieldEntries(
|
||||
getSectionConfig(sectionKey, level).hiddenFields,
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveHiddenFieldEntries(
|
||||
entries: SectionConfig["hiddenFields"] | undefined,
|
||||
config: FrigateConfig | undefined,
|
||||
): string[] {
|
||||
if (!entries || entries.length === 0) return [];
|
||||
const result: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (typeof entry === "function") {
|
||||
if (config) result.push(...entry(config));
|
||||
} else {
|
||||
result.push(entry);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -1072,10 +1072,12 @@ function FrigateCameraFeatures({
|
||||
title={
|
||||
recordState == "ON"
|
||||
? t("recording.disable")
|
||||
: t("recording.enable")
|
||||
: camera.record.enabled_in_config
|
||||
? t("recording.enable")
|
||||
: t("recording.disabledInConfig")
|
||||
}
|
||||
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
|
||||
disabled={!cameraEnabled}
|
||||
disabled={!cameraEnabled || !camera.record.enabled_in_config}
|
||||
/>
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
|
||||
@ -313,12 +313,18 @@ type CameraEnableSwitchProps = {
|
||||
function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
|
||||
const { payload: enabledState, send: sendEnabled } =
|
||||
useEnabledState(cameraName);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const isChecked =
|
||||
enabledState === "ON" || enabledState === "OFF"
|
||||
? enabledState === "ON"
|
||||
: (config?.cameras?.[cameraName]?.enabled ?? false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id={`camera-enabled-${cameraName}`}
|
||||
checked={enabledState === "ON"}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendEnabled(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
|
||||
@ -2,6 +2,7 @@ import { useFrigateStats } from "@/api/ws";
|
||||
import { CameraLineGraph } from "@/components/graph/LineGraph";
|
||||
import CameraInfoDialog from "@/components/overlay/CameraInfoDialog";
|
||||
import { ConnectionQualityIndicator } from "@/components/camera/ConnectionQualityIndicator";
|
||||
import { EmptyCard } from "@/components/card/EmptyCard";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
@ -13,6 +14,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { BsFillCameraVideoOffFill } from "react-icons/bs";
|
||||
import { MdInfo } from "react-icons/md";
|
||||
import {
|
||||
Tooltip,
|
||||
@ -173,7 +175,7 @@ export default function CameraMetrics({
|
||||
}
|
||||
|
||||
Object.entries(stats.cameras).forEach(([key, camStats]) => {
|
||||
if (!config?.cameras[key].enabled) {
|
||||
if (!camStats || !config?.cameras[key]?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -228,6 +230,10 @@ export default function CameraMetrics({
|
||||
}
|
||||
|
||||
Object.entries(stats.cameras).forEach(([key, camStats]) => {
|
||||
if (!camStats) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(key in series)) {
|
||||
const camName = getCameraName(key);
|
||||
series[key] = {};
|
||||
@ -274,6 +280,17 @@ export default function CameraMetrics({
|
||||
}
|
||||
}, [showCameraInfoDialog]);
|
||||
|
||||
if (config && Object.keys(config.cameras).length === 0) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<EmptyCard
|
||||
icon={<BsFillCameraVideoOffFill className="size-8" />}
|
||||
title={t("cameras.noCameras.title")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scrollbar-container mt-4 flex size-full flex-col gap-3 overflow-y-auto">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user