diff --git a/frigate/config/camera/zone.py b/frigate/config/camera/zone.py index 3e69240d5..530ba1cf9 100644 --- a/frigate/config/camera/zone.py +++ b/frigate/config/camera/zone.py @@ -13,6 +13,9 @@ logger = logging.getLogger(__name__) class ZoneConfig(BaseModel): + friendly_name: Optional[str] = Field( + None, title="Zone friendly name used in the Frigate UI." + ) filters: dict[str, FilterConfig] = Field( default_factory=dict, title="Zone filters." ) diff --git a/web/src/components/camera/CameraNameLabel.tsx b/web/src/components/camera/FriendlyNameLabel.tsx similarity index 53% rename from web/src/components/camera/CameraNameLabel.tsx rename to web/src/components/camera/FriendlyNameLabel.tsx index ab022f5c8..ca0978852 100644 --- a/web/src/components/camera/CameraNameLabel.tsx +++ b/web/src/components/camera/FriendlyNameLabel.tsx @@ -2,12 +2,19 @@ import * as React from "react"; import * as LabelPrimitive from "@radix-ui/react-label"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { CameraConfig } from "@/types/frigateConfig"; +import { useZoneFriendlyName } from "@/hooks/use-zone-friendly-name"; interface CameraNameLabelProps extends React.ComponentPropsWithoutRef { camera?: string | CameraConfig; } +interface ZoneNameLabelProps + extends React.ComponentPropsWithoutRef { + zone: string; + camera?: string; +} + const CameraNameLabel = React.forwardRef< React.ElementRef, CameraNameLabelProps @@ -21,4 +28,17 @@ const CameraNameLabel = React.forwardRef< }); CameraNameLabel.displayName = LabelPrimitive.Root.displayName; -export { CameraNameLabel }; +const ZoneNameLabel = React.forwardRef< + React.ElementRef, + ZoneNameLabelProps +>(({ className, zone, camera, ...props }, ref) => { + const displayName = useZoneFriendlyName(zone, camera); + return ( + + {displayName} + + ); +}); +ZoneNameLabel.displayName = LabelPrimitive.Root.displayName; + +export { CameraNameLabel, ZoneNameLabel }; diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index cd0b118c9..a700981b6 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -76,7 +76,7 @@ import { CameraStreamingDialog } from "../settings/CameraStreamingDialog"; import { DialogTrigger } from "@radix-ui/react-dialog"; import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { Trans, useTranslation } from "react-i18next"; -import { CameraNameLabel } from "../camera/CameraNameLabel"; +import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useIsCustomRole } from "@/hooks/use-is-custom-role"; diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx index 93b8a8651..1974ab9e8 100644 --- a/web/src/components/filter/CamerasFilterButton.tsx +++ b/web/src/components/filter/CamerasFilterButton.tsx @@ -190,7 +190,7 @@ export function CamerasFilterContent({ key={item} isChecked={currentCameras?.includes(item) ?? false} label={item} - isCameraName={true} + type="camera" disabled={ mainCamera !== undefined && currentCameras !== undefined && diff --git a/web/src/components/filter/FilterSwitch.tsx b/web/src/components/filter/FilterSwitch.tsx index fa8709d96..253f687f9 100644 --- a/web/src/components/filter/FilterSwitch.tsx +++ b/web/src/components/filter/FilterSwitch.tsx @@ -1,29 +1,38 @@ import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; -import { CameraNameLabel } from "../camera/CameraNameLabel"; +import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel"; type FilterSwitchProps = { label: string; disabled?: boolean; isChecked: boolean; - isCameraName?: boolean; + type?: string; + extraValue?: string; onCheckedChange: (checked: boolean) => void; }; export default function FilterSwitch({ label, disabled = false, isChecked, - isCameraName = false, + type = "", + extraValue = "", onCheckedChange, }: FilterSwitchProps) { return (
- {isCameraName ? ( + {type === "camera" ? ( + ) : type === "zone" ? ( + ) : (
masksAndZones.form.polygonDrawing.delete.desc diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 8a1e36a0e..91d51db15 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -34,6 +34,7 @@ import { Link } from "react-router-dom"; import { LuExternalLink } from "react-icons/lu"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { getTranslatedLabel } from "@/utils/i18n"; +import { processZoneName } from "@/utils/zoneUtil"; type ZoneEditPaneProps = { polygons?: Polygon[]; @@ -146,15 +147,7 @@ export default function ZoneEditPane({ "masksAndZones.form.zoneName.error.mustNotContainPeriod", ), }, - ) - .refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), { - message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"), - }) - .refine((value: string) => /[a-zA-Z]/.test(value), { - message: t( - "masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter", - ), - }), + ), inertia: z.coerce .number() .min(1, { @@ -246,7 +239,11 @@ export default function ZoneEditPane({ resolver: zodResolver(formSchema), mode: "onBlur", defaultValues: { - name: polygon?.name ?? "", + name: + polygon?.camera && polygon?.name + ? config?.cameras[polygon.camera]?.zones[polygon.name] + ?.friendly_name || polygon?.name + : "", inertia: polygon?.camera && polygon?.name && @@ -305,7 +302,12 @@ export default function ZoneEditPane({ let alertQueries = ""; let detectionQueries = ""; - const renamingZone = zoneName != polygon.name && polygon.name != ""; + const renamingZone = + zoneName != polygon.name && + zoneName != polygon.friendlyName && + polygon.name != ""; + + const { finalZoneName, friendlyName } = processZoneName(zoneName); if (renamingZone) { // rename - delete old zone and replace with new @@ -349,7 +351,7 @@ export default function ZoneEditPane({ // make sure new zone name is readded to review ({ alertQueries, detectionQueries } = reviewQueries( - zoneName, + finalZoneName, zoneInAlerts, zoneInDetections, polygon.camera, @@ -367,7 +369,7 @@ export default function ZoneEditPane({ let objectQueries = objects .map( (object) => - `&cameras.${polygon?.camera}.zones.${zoneName}.objects=${object}`, + `&cameras.${polygon?.camera}.zones.${finalZoneName}.objects=${object}`, ) .join(""); @@ -379,45 +381,50 @@ export default function ZoneEditPane({ // deleting objects if (!objectQueries && !same_objects && !renamingZone) { - objectQueries = `&cameras.${polygon?.camera}.zones.${zoneName}.objects`; + objectQueries = `&cameras.${polygon?.camera}.zones.${finalZoneName}.objects`; } let inertiaQuery = ""; if (inertia) { - inertiaQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.inertia=${inertia}`; + inertiaQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.inertia=${inertia}`; } let loiteringTimeQuery = ""; if (loitering_time >= 0) { - loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`; + loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.loitering_time=${loitering_time}`; } let distancesQuery = ""; const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(","); if (speedEstimation) { - distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances=${distances}`; + distancesQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.distances=${distances}`; } else { if (distances != "") { - distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances`; + distancesQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.distances`; } } let speedThresholdQuery = ""; if (speed_threshold >= 0 && speedEstimation) { - speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold=${speed_threshold}`; + speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.speed_threshold=${speed_threshold}`; } else { if ( polygon?.camera && polygon?.name && config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold ) { - speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold`; + speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.speed_threshold`; } } + let friendlyNameQuery = ""; + if (friendlyName) { + friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${finalZoneName}.friendly_name=${encodeURIComponent(friendlyName)}`; + } + axios .put( - `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`, + `config/set?cameras.${polygon?.camera}.zones.${finalZoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`, { requires_restart: 0, update_topic: `config/cameras/${polygon.camera}/zones`, @@ -427,7 +434,7 @@ export default function ZoneEditPane({ if (res.status === 200) { toast.success( t("masksAndZones.zones.toast.success", { - zoneName, + zoneName: friendlyName, }), { position: "top-center", diff --git a/web/src/hooks/use-zone-friendly-name.ts b/web/src/hooks/use-zone-friendly-name.ts new file mode 100644 index 000000000..18ce2a45c --- /dev/null +++ b/web/src/hooks/use-zone-friendly-name.ts @@ -0,0 +1,41 @@ +import { FrigateConfig } from "@/types/frigateConfig"; +import { useMemo } from "react"; +import useSWR from "swr"; + +export function resolveZoneName( + config: FrigateConfig | undefined, + zoneId: string, + cameraId?: string, +) { + if (!config) return String(zoneId).replace(/_/g, " "); + + if (cameraId) { + const camera = config.cameras?.[String(cameraId)]; + const zone = camera?.zones?.[zoneId]; + return zone?.friendly_name || String(zoneId).replace(/_/g, " "); + } + + for (const camKey in config.cameras) { + if (!Object.prototype.hasOwnProperty.call(config.cameras, camKey)) continue; + const cam = config.cameras[camKey]; + if (!cam?.zones) continue; + if (Object.prototype.hasOwnProperty.call(cam.zones, zoneId)) { + const zone = cam.zones[zoneId]; + return zone?.friendly_name || String(zoneId).replace(/_/g, " "); + } + } + + // Fallback: return a cleaned-up zoneId string + return String(zoneId).replace(/_/g, " "); +} + +export function useZoneFriendlyName(zoneId: string, cameraId?: string): string { + const { data: config } = useSWR("config"); + + const name = useMemo( + () => resolveZoneName(config, zoneId, cameraId), + [config, cameraId, zoneId], + ); + + return name; +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 844329fc7..ba81730d5 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -42,7 +42,7 @@ import { useInitialCameraState } from "@/api/ws"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { useTranslation } from "react-i18next"; import TriggerView from "@/views/settings/TriggerView"; -import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { Sidebar, SidebarContent, @@ -642,7 +642,7 @@ function CameraSelectButton({ key={item.name} isChecked={item.name === selectedCamera} label={item.name} - isCameraName={true} + type={"camera"} onCheckedChange={(isChecked) => { if (isChecked && (isEnabled || isCameraSettingsPage)) { setSelectedCamera(item.name); diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 9c1748ce0..0326038ea 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -4,6 +4,7 @@ export type Polygon = { typeIndex: number; camera: string; name: string; + friendlyName?: string; type: PolygonType; objects: string[]; points: number[][]; diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index f82ca9838..0fc540f1f 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -272,6 +272,7 @@ export interface CameraConfig { webui_url: string | null; zones: { [zoneName: string]: { + friendly_name?: string; coordinates: string; distances: string[]; filters: Record; diff --git a/web/src/utils/cameraUtil.ts b/web/src/utils/cameraUtil.ts index ae7b8001a..94035dbae 100644 --- a/web/src/utils/cameraUtil.ts +++ b/web/src/utils/cameraUtil.ts @@ -1,29 +1,7 @@ +import { generateFixedHash } from "./stringUtil"; + // ==================== Camera Name Processing ==================== -/** - * Generates a fixed-length hash from a camera name for use as a valid camera identifier. - * Works safely with Unicode input while outputting Latin-only identifiers. - * - * @param name - The original camera name/display name - * @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars) - */ -export function generateFixedHash(name: string): string { - // Safely encode Unicode as UTF-8 bytes - const utf8Bytes = new TextEncoder().encode(name); - - // Convert to base64 manually - let binary = ""; - for (const byte of utf8Bytes) { - binary += String.fromCharCode(byte); - } - const base64 = btoa(binary); - - // Strip out non-alphanumeric characters and truncate - const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8); - - return `cam_${cleanHash.toLowerCase()}`; -} - /** * Checks if a string is a valid camera name identifier. * Valid camera names contain only ASCII letters, numbers, underscores, and hyphens. diff --git a/web/src/utils/stringUtil.ts b/web/src/utils/stringUtil.ts index 57f142119..8bc940f25 100644 --- a/web/src/utils/stringUtil.ts +++ b/web/src/utils/stringUtil.ts @@ -9,3 +9,31 @@ export const capitalizeAll = (text: string): string => { .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); }; + +/** + * Generates a fixed-length hash from a camera name for use as a valid camera identifier. + * Works safely with Unicode input while outputting Latin-only identifiers. + * + * @param name - The original camera/zones name/display name + * @param prefix - The prefix to use for the generated camera/zones name (default: "cam_") + * @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars) + */ +export function generateFixedHash( + name: string, + prefix: string = "cam_", +): string { + // Safely encode Unicode as UTF-8 bytes + const utf8Bytes = new TextEncoder().encode(name); + + // Convert to base64 manually + let binary = ""; + for (const byte of utf8Bytes) { + binary += String.fromCharCode(byte); + } + const base64 = btoa(binary); + + // Strip out non-alphanumeric characters and truncate + const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8); + + return `${prefix}_${cleanHash.toLowerCase()}`; +} diff --git a/web/src/utils/zoneUtil.ts b/web/src/utils/zoneUtil.ts new file mode 100644 index 000000000..8d75c3352 --- /dev/null +++ b/web/src/utils/zoneUtil.ts @@ -0,0 +1,21 @@ +import { isValidCameraName } from "./cameraUtil.ts"; +import { generateFixedHash } from "./stringUtil.ts"; + +export function processZoneName(userInput: string): { + finalZoneName: string; + friendlyName?: string; +} { + const normalizedInput = userInput.replace(/\s+/g, "_").toLowerCase(); + + if (isValidCameraName(normalizedInput)) { + return { + finalZoneName: normalizedInput, + friendlyName: userInput.includes(" ") ? userInput : undefined, + }; + } + + return { + finalZoneName: generateFixedHash(userInput, "zone"), + friendlyName: userInput, + }; +} diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index aee60b6b2..f6b84e874 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -65,7 +65,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { DetailStreamProvider } from "@/context/detail-stream-context"; import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip"; diff --git a/web/src/views/settings/AuthenticationView.tsx b/web/src/views/settings/AuthenticationView.tsx index db5f22555..5c11d8914 100644 --- a/web/src/views/settings/AuthenticationView.tsx +++ b/web/src/views/settings/AuthenticationView.tsx @@ -36,7 +36,7 @@ import EditRoleCamerasDialog from "@/components/overlay/EditRoleCamerasDialog"; import { useTranslation } from "react-i18next"; import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog"; import { Separator } from "@/components/ui/separator"; -import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; type AuthenticationViewProps = { section?: "users" | "roles"; diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 22c44fc9e..4f3775901 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -18,7 +18,7 @@ import { } from "@/components/ui/select"; import { IoMdArrowRoundBack } from "react-icons/io"; import { isDesktop } from "react-device-detect"; -import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { Switch } from "@/components/ui/switch"; import { Trans } from "react-i18next"; import { Separator } from "@/components/ui/separator"; diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index f42ec84fe..53ee164db 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -42,6 +42,7 @@ import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import { IoMdArrowRoundBack } from "react-icons/io"; import { isDesktop } from "react-device-detect"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; +import { resolveZoneName } from "@/hooks/use-zone-friendly-name"; type CameraSettingsViewProps = { selectedCamera: string; @@ -86,16 +87,23 @@ export default function CameraSettingsView({ // zones and labels + const getZoneName = useCallback( + (cameraId: string, zoneId: string) => + resolveZoneName(config, zoneId, cameraId), + [config], + ); + const zones = useMemo(() => { if (cameraConfig) { return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ camera: cameraConfig.name, name, + friendly_name: getZoneName(cameraConfig.name, name), objects: zoneData.objects, color: zoneData.color, })); } - }, [cameraConfig]); + }, [cameraConfig, getZoneName]); const alertsLabels = useMemo(() => { return cameraConfig?.review.alerts.labels @@ -526,7 +534,7 @@ export default function CameraSettingsView({ /> - {zone.name.replaceAll("_", " ")} + {zone.friendly_name} )} @@ -628,7 +636,7 @@ export default function CameraSettingsView({ /> - {zone.name.replaceAll("_", " ")} + {zone.friendly_name} )} diff --git a/web/src/views/settings/FrigatePlusSettingsView.tsx b/web/src/views/settings/FrigatePlusSettingsView.tsx index 20e248070..52af94354 100644 --- a/web/src/views/settings/FrigatePlusSettingsView.tsx +++ b/web/src/views/settings/FrigatePlusSettingsView.tsx @@ -23,7 +23,7 @@ import { SelectTrigger, } from "@/components/ui/select"; import { useDocDomain } from "@/hooks/use-doc-domain"; -import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; type FrigatePlusModel = { id: string; diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index 956ce3f95..83419c6cc 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -229,6 +229,7 @@ export default function MasksAndZonesView({ typeIndex: index, camera: cameraConfig.name, name, + friendlyName: zoneData.friendly_name, objects: zoneData.objects, points: interpolatePoints( parseCoordinates(zoneData.coordinates), diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index b3a049418..6280ca6a8 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -45,7 +45,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Trans, useTranslation } from "react-i18next"; import { useDateLocale } from "@/hooks/use-date-locale"; import { useDocDomain } from "@/hooks/use-doc-domain"; -import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { cn } from "@/lib/utils"; @@ -476,7 +476,7 @@ export default function NotificationView({