mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-07 05:55:27 +03:00
Miscellaneous fixes (#23124)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* use continuous expire date when loading reviews for recording cleanup * reset heatmap filter when motion preview camera changes * Add note about speed zones unit when enabled * don't display fps warning for dedicated LPR cameras * language tweaks * allow changing camera type from management UI * i18n * fix ollama tool calling failure when conversation contains multimodal content from live frame tool results * fix mypy --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
parent
704ee9667c
commit
5211590866
@ -37,6 +37,7 @@ from frigate.api.defs.response.chat_response import (
|
|||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.api.event import events
|
from frigate.api.event import events
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.ui import UnitSystemEnum
|
||||||
from frigate.genai.utils import build_assistant_message_for_conversation
|
from frigate.genai.utils import build_assistant_message_for_conversation
|
||||||
from frigate.jobs.vlm_watch import (
|
from frigate.jobs.vlm_watch import (
|
||||||
get_vlm_watch_job,
|
get_vlm_watch_job,
|
||||||
@ -1301,6 +1302,7 @@ async def chat_completion(
|
|||||||
|
|
||||||
cameras_info = []
|
cameras_info = []
|
||||||
config = request.app.frigate_config
|
config = request.app.frigate_config
|
||||||
|
has_speed_zone = False
|
||||||
for camera_id in allowed_cameras:
|
for camera_id in allowed_cameras:
|
||||||
if camera_id not in config.cameras:
|
if camera_id not in config.cameras:
|
||||||
continue
|
continue
|
||||||
@ -1311,6 +1313,10 @@ async def chat_completion(
|
|||||||
else camera_id.replace("_", " ").title()
|
else camera_id.replace("_", " ").title()
|
||||||
)
|
)
|
||||||
zone_names = list(camera_config.zones.keys())
|
zone_names = list(camera_config.zones.keys())
|
||||||
|
if not has_speed_zone:
|
||||||
|
has_speed_zone = any(
|
||||||
|
zone.distances for zone in camera_config.zones.values()
|
||||||
|
)
|
||||||
if zone_names:
|
if zone_names:
|
||||||
cameras_info.append(
|
cameras_info.append(
|
||||||
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
|
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
|
||||||
@ -1326,6 +1332,13 @@ async def chat_completion(
|
|||||||
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
|
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
speed_units_section = ""
|
||||||
|
if has_speed_zone:
|
||||||
|
speed_unit = (
|
||||||
|
"mph" if config.ui.unit_system == UnitSystemEnum.imperial else "km/h"
|
||||||
|
)
|
||||||
|
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
|
||||||
|
|
||||||
system_prompt = f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
|
system_prompt = f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
|
||||||
|
|
||||||
Current server local date and time: {current_date_str} at {current_time_str}
|
Current server local date and time: {current_date_str} at {current_time_str}
|
||||||
@ -1337,7 +1350,7 @@ When users ask about "today", "yesterday", "this week", etc., use the current da
|
|||||||
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
|
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
|
||||||
Always be accurate with time calculations based on the current date provided.
|
Always be accurate with time calculations based on the current date provided.
|
||||||
|
|
||||||
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{cameras_section}"""
|
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{cameras_section}{speed_units_section}"""
|
||||||
|
|
||||||
conversation.append(
|
conversation.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Ollama Provider for Frigate AI."""
|
"""Ollama Provider for Frigate AI."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, AsyncGenerator, Optional
|
from typing import Any, AsyncGenerator, Optional
|
||||||
@ -16,6 +18,41 @@ from frigate.genai.utils import parse_tool_calls_from_message
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_multimodal_content(
|
||||||
|
content: Any,
|
||||||
|
) -> tuple[Optional[str], Optional[list[bytes]]]:
|
||||||
|
"""Convert OpenAI-style multimodal content to Ollama's (text, images) shape.
|
||||||
|
|
||||||
|
The chat API constructs user messages with content as a list of
|
||||||
|
``{"type": "text"}`` and ``{"type": "image_url"}`` parts when a tool
|
||||||
|
returns a live frame. Ollama's SDK requires content to be a string and
|
||||||
|
images to be passed in a separate field, so we extract each.
|
||||||
|
"""
|
||||||
|
if not isinstance(content, list):
|
||||||
|
return content, None
|
||||||
|
|
||||||
|
text_parts: list[str] = []
|
||||||
|
images: list[bytes] = []
|
||||||
|
for part in content:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
part_type = part.get("type")
|
||||||
|
if part_type == "text":
|
||||||
|
text = part.get("text")
|
||||||
|
if text:
|
||||||
|
text_parts.append(str(text))
|
||||||
|
elif part_type == "image_url":
|
||||||
|
url = (part.get("image_url") or {}).get("url", "")
|
||||||
|
if isinstance(url, str) and url.startswith("data:"):
|
||||||
|
try:
|
||||||
|
encoded = url.split(",", 1)[1]
|
||||||
|
images.append(base64.b64decode(encoded, validate=True))
|
||||||
|
except (ValueError, IndexError, binascii.Error) as e:
|
||||||
|
logger.debug("Failed to decode multimodal image url: %s", e)
|
||||||
|
|
||||||
|
return ("\n".join(text_parts) if text_parts else None), (images or None)
|
||||||
|
|
||||||
|
|
||||||
@register_genai_provider(GenAIProviderEnum.ollama)
|
@register_genai_provider(GenAIProviderEnum.ollama)
|
||||||
class OllamaClient(GenAIClient):
|
class OllamaClient(GenAIClient):
|
||||||
"""Generative AI client for Frigate using Ollama."""
|
"""Generative AI client for Frigate using Ollama."""
|
||||||
@ -207,10 +244,13 @@ class OllamaClient(GenAIClient):
|
|||||||
"""Build request_messages and params for chat (sync or stream)."""
|
"""Build request_messages and params for chat (sync or stream)."""
|
||||||
request_messages = []
|
request_messages = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
msg_dict = {
|
content, images = _normalize_multimodal_content(msg.get("content", ""))
|
||||||
|
msg_dict: dict[str, Any] = {
|
||||||
"role": msg.get("role"),
|
"role": msg.get("role"),
|
||||||
"content": msg.get("content", ""),
|
"content": content if content is not None else "",
|
||||||
}
|
}
|
||||||
|
if images:
|
||||||
|
msg_dict["images"] = images
|
||||||
if msg.get("tool_call_id"):
|
if msg.get("tool_call_id"):
|
||||||
msg_dict["tool_call_id"] = msg["tool_call_id"]
|
msg_dict["tool_call_id"] = msg["tool_call_id"]
|
||||||
if msg.get("name"):
|
if msg.get("name"):
|
||||||
|
|||||||
@ -351,9 +351,11 @@ class RecordingCleanup(threading.Thread):
|
|||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
ReviewSegment.camera == camera,
|
ReviewSegment.camera == camera,
|
||||||
# need to ensure segments for all reviews starting
|
# candidate recordings can extend up to continuous_expire_date
|
||||||
# before the expire date are included
|
# (the no-motion no-audio branch of the recordings query),
|
||||||
ReviewSegment.start_time < motion_expire_date,
|
# so reviews must cover that full range to avoid deleting
|
||||||
|
# segments that overlap recent alerts/detections.
|
||||||
|
ReviewSegment.start_time < continuous_expire_date,
|
||||||
)
|
)
|
||||||
.order_by(ReviewSegment.start_time)
|
.order_by(ReviewSegment.start_time)
|
||||||
.namedtuples()
|
.namedtuples()
|
||||||
|
|||||||
@ -512,6 +512,14 @@
|
|||||||
"inherit": "Inherit",
|
"inherit": "Inherit",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
|
},
|
||||||
|
"cameraType": {
|
||||||
|
"title": "Camera Type",
|
||||||
|
"label": "Camera type",
|
||||||
|
"description": "Set the type for each camera. Dedicated LPR cameras are single-purpose cameras with powerful optical zoom to capture license plates on distant vehicles. Most cameras should use the normal camera type unless the camera is specifically for LPR and has a tightly focused view on license plates.",
|
||||||
|
"normal": "Normal",
|
||||||
|
"dedicatedLpr": "Dedicated LPR",
|
||||||
|
"saveSuccess": "Updated camera type for {{cameraName}}. Restart Frigate to apply the changes."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cameraReview": {
|
"cameraReview": {
|
||||||
@ -1652,14 +1660,14 @@
|
|||||||
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active."
|
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active."
|
||||||
},
|
},
|
||||||
"detect": {
|
"detect": {
|
||||||
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended."
|
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit."
|
||||||
},
|
},
|
||||||
"faceRecognition": {
|
"faceRecognition": {
|
||||||
"globalDisabled": "Face recognition is not enabled at the global level. Enable it in global settings for camera-level face recognition to function.",
|
"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."
|
"personNotTracked": "Face recognition requires the 'person' object to be tracked. Ensure 'person' is in the object tracking list."
|
||||||
},
|
},
|
||||||
"lpr": {
|
"lpr": {
|
||||||
"globalDisabled": "License plate recognition is not enabled at the global level. Enable it in global settings for camera-level LPR to function.",
|
"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."
|
"vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked."
|
||||||
},
|
},
|
||||||
"record": {
|
"record": {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ const detect: SectionConfigOverrides = {
|
|||||||
position: "after",
|
position: "after",
|
||||||
condition: (ctx) => {
|
condition: (ctx) => {
|
||||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||||
|
if (ctx.fullCameraConfig.type === "lpr") return false;
|
||||||
const detectFps = ctx.formData?.fps as number | undefined;
|
const detectFps = ctx.formData?.fps as number | undefined;
|
||||||
const streamFps = ctx.fullCameraConfig.detect?.fps;
|
const streamFps = ctx.fullCameraConfig.detect?.fps;
|
||||||
return detectFps != null && streamFps != null && detectFps > 5;
|
return detectFps != null && streamFps != null && detectFps > 5;
|
||||||
|
|||||||
@ -1136,6 +1136,12 @@ function MotionReview({
|
|||||||
);
|
);
|
||||||
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
|
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
|
||||||
|
|
||||||
|
// reset filter when camera changes
|
||||||
|
useEffect(() => {
|
||||||
|
setMotionFilterCells(new Set());
|
||||||
|
setPendingFilterCells(new Set());
|
||||||
|
}, [motionPreviewsCamera]);
|
||||||
|
|
||||||
const objectReviewItems = useMemo(
|
const objectReviewItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(overlapReviewSegments ?? []).filter(
|
(overlapReviewSegments ?? []).filter(
|
||||||
|
|||||||
@ -14,8 +14,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||||
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
|
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
|
||||||
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
|
import { LuExternalLink, LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@ -89,6 +91,13 @@ export default function CameraManagementView({
|
|||||||
return [];
|
return [];
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
|
const allCameras = useMemo(() => {
|
||||||
|
if (config) {
|
||||||
|
return Object.keys(config.cameras).sort();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("documentTitle.cameraManagement");
|
document.title = t("documentTitle.cameraManagement");
|
||||||
}, [t]);
|
}, [t]);
|
||||||
@ -235,6 +244,15 @@ export default function CameraManagementView({
|
|||||||
onConfigChanged={updateConfig}
|
onConfigChanged={updateConfig}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{config?.lpr?.enabled && allCameras.length > 0 && (
|
||||||
|
<CameraTypeSection
|
||||||
|
cameras={allCameras}
|
||||||
|
config={config}
|
||||||
|
onConfigChanged={updateConfig}
|
||||||
|
setRestartDialogOpen={setRestartDialogOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -497,6 +515,196 @@ function CameraConfigEnableSwitch({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CameraTypeSectionProps = {
|
||||||
|
cameras: string[];
|
||||||
|
config: FrigateConfig | undefined;
|
||||||
|
onConfigChanged: () => Promise<unknown>;
|
||||||
|
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CameraTypeSection({
|
||||||
|
cameras,
|
||||||
|
config,
|
||||||
|
onConfigChanged,
|
||||||
|
setRestartDialogOpen,
|
||||||
|
}: CameraTypeSectionProps) {
|
||||||
|
const { t } = useTranslation([
|
||||||
|
"views/settings",
|
||||||
|
"common",
|
||||||
|
"components/dialog",
|
||||||
|
]);
|
||||||
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
|
const [savingCamera, setSavingCamera] = useState<string | null>(null);
|
||||||
|
// Optimistic local state: the parsed config API doesn't reflect type
|
||||||
|
// changes until Frigate restarts, so we track saved values locally.
|
||||||
|
const [localOverrides, setLocalOverrides] = useState<Record<string, string>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTypeChange = useCallback(
|
||||||
|
async (camera: string, value: string) => {
|
||||||
|
setSavingCamera(camera);
|
||||||
|
try {
|
||||||
|
const typeValue = value === "lpr" ? "lpr" : null;
|
||||||
|
await axios.put("config/set", {
|
||||||
|
requires_restart: 1,
|
||||||
|
config_data: {
|
||||||
|
cameras: {
|
||||||
|
[camera]: {
|
||||||
|
type: typeValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await onConfigChanged();
|
||||||
|
|
||||||
|
setLocalOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[camera]: value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
t("cameraManagement.cameraType.saveSuccess", {
|
||||||
|
ns: "views/settings",
|
||||||
|
cameraName: camera,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
action: (
|
||||||
|
<a onClick={() => setRestartDialogOpen(true)}>
|
||||||
|
<Button>
|
||||||
|
{t("restart.button", { ns: "components/dialog" })}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
axios.isAxiosError(error) &&
|
||||||
|
(error.response?.data?.message || error.response?.data?.detail)
|
||||||
|
? error.response?.data?.message || error.response?.data?.detail
|
||||||
|
: t("toast.save.error.noMessage", { ns: "common" });
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSavingCamera(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onConfigChanged, setRestartDialogOpen, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCameraType = useCallback(
|
||||||
|
(camera: string): string => {
|
||||||
|
const localValue = localOverrides[camera];
|
||||||
|
if (localValue) return localValue;
|
||||||
|
|
||||||
|
const type = config?.cameras?.[camera]?.type;
|
||||||
|
return type === "lpr" ? "lpr" : "normal";
|
||||||
|
},
|
||||||
|
[config, localOverrides],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroupCard
|
||||||
|
title={t("cameraManagement.cameraType.title", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className={SPLIT_ROW_CLASS_NAME}>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>
|
||||||
|
{t("cameraManagement.cameraType.label", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
<RestartRequiredIndicator className="ml-1" />
|
||||||
|
</Label>
|
||||||
|
<p className="hidden text-sm text-muted-foreground md:block">
|
||||||
|
{t("cameraManagement.cameraType.description", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="hidden items-center text-sm text-primary md:flex">
|
||||||
|
<Link
|
||||||
|
to={getLocaleDocUrl(
|
||||||
|
"configuration/license_plate_recognition#dedicated-lpr-cameras",
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
{t("readTheDocumentation", { ns: "common" })}
|
||||||
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}>
|
||||||
|
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||||
|
{cameras.map((camera) => {
|
||||||
|
const currentType = getCameraType(camera);
|
||||||
|
const isSaving = savingCamera === camera;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={camera}
|
||||||
|
className="flex flex-row items-center justify-between"
|
||||||
|
>
|
||||||
|
<CameraNameLabel camera={camera} />
|
||||||
|
{isSaving ? (
|
||||||
|
<ActivityIndicator className="h-5 w-20" size={16} />
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={currentType}
|
||||||
|
onValueChange={(v) => handleTypeChange(camera, v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-full max-w-[140px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="normal">
|
||||||
|
{t("cameraManagement.cameraType.normal", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="lpr">
|
||||||
|
{t("cameraManagement.cameraType.dedicatedLpr", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground md:hidden">
|
||||||
|
{t("cameraManagement.cameraType.description", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center text-sm text-primary md:hidden">
|
||||||
|
<Link
|
||||||
|
to={getLocaleDocUrl(
|
||||||
|
"configuration/license_plate_recognition#dedicated-lpr-cameras",
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
{t("readTheDocumentation", { ns: "common" })}
|
||||||
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsGroupCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ProfileCameraEnableSectionProps = {
|
type ProfileCameraEnableSectionProps = {
|
||||||
profileState: ProfileState;
|
profileState: ProfileState;
|
||||||
cameras: string[];
|
cameras: string[];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user