diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index b81ceef899..23e18c2c97 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -810,6 +810,8 @@ cameras: # NOTE: This must be different than any camera names, but can match with another zone on another # camera. front_steps: + # Optional: A friendly name or descriptive text for the zones + friendly_name: "" # Required: List of x,y coordinates to define the polygon of the zone. # NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box. coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428 diff --git a/docs/docs/configuration/zones.md b/docs/docs/configuration/zones.md index 025384a8bb..c0a11d4f66 100644 --- a/docs/docs/configuration/zones.md +++ b/docs/docs/configuration/zones.md @@ -27,6 +27,7 @@ cameras: - entire_yard zones: entire_yard: + friendly_name: Entire yard # You can use characters from any language text coordinates: ... ``` @@ -44,8 +45,10 @@ cameras: - edge_yard zones: edge_yard: + friendly_name: Edge yard # You can use characters from any language text coordinates: ... inner_yard: + friendly_name: Inner yard # You can use characters from any language text coordinates: ... ``` @@ -59,6 +62,7 @@ cameras: - entire_yard zones: entire_yard: + friendly_name: Entire yard coordinates: ... ``` @@ -82,6 +86,7 @@ cameras: Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street. + ### Zone Loitering Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone. diff --git a/frigate/config/camera/zone.py b/frigate/config/camera/zone.py index 3e69240d51..530ba1cf91 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/frigate/timeline.py b/frigate/timeline.py index 8e6aedc679..ae4abb447d 100644 --- a/frigate/timeline.py +++ b/frigate/timeline.py @@ -131,6 +131,19 @@ class TimelineProcessor(threading.Thread): ): timeline_entry[Timeline.class_type] = "entered_zone" timeline_entry[Timeline.data]["zones"] = event_data["current_zones"] + + zones_friendly = [] + for z in event_data["current_zones"]: + zone_cfg = camera_config.zones.get(z) + if zone_cfg is not None and getattr( + zone_cfg, "friendly_name", None + ): + zones_friendly.append(zone_cfg.friendly_name) + else: + zones_friendly.append(z) + + timeline_entry[Timeline.data]["zones_friendly_names"] = zones_friendly + save = True elif prev_event_data["stationary"] != event_data["stationary"]: timeline_entry[Timeline.class_type] = ( 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 ab022f5c87..ca09788529 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 cd0b118c99..a700981b6d 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 93b8a8651a..baeccf06f5 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 fa8709d967..d282b9e124 100644 --- a/web/src/components/filter/FilterSwitch.tsx +++ b/web/src/components/filter/FilterSwitch.tsx @@ -1,29 +1,39 @@ 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 8a1e36a0e5..3a0e23d1fe 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 NameAndIdFields from "../input/NameAndIdFields"; type ZoneEditPaneProps = { polygons?: Polygon[]; @@ -146,15 +147,37 @@ 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), { + ), + friendly_name: z + .string() + .min(2, { message: t( - "masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter", + "masksAndZones.form.zoneName.error.mustBeAtLeastTwoCharacters", ), - }), + }) + .refine( + (value: string) => { + return !cameras.map((cam) => cam.name).includes(value); + }, + { + message: t( + "masksAndZones.form.zoneName.error.mustNotBeSameWithCamera", + ), + }, + ) + .refine( + (value: string) => { + const otherPolygonNames = + polygons + ?.filter((_, index) => index !== activePolygonIndex) + .map((polygon) => polygon.name) || []; + + return !otherPolygonNames.includes(value); + }, + { + message: t("masksAndZones.form.zoneName.error.alreadyExists"), + }, + ), inertia: z.coerce .number() .min(1, { @@ -247,6 +270,7 @@ export default function ZoneEditPane({ mode: "onBlur", defaultValues: { name: polygon?.name ?? "", + friendly_name: polygon?.friendly_name ?? polygon?.name ?? "", inertia: polygon?.camera && polygon?.name && @@ -286,6 +310,7 @@ export default function ZoneEditPane({ async ( { name: zoneName, + friendly_name, inertia, loitering_time, objects: form_objects, @@ -415,9 +440,14 @@ export default function ZoneEditPane({ } } + let friendlyNameQuery = ""; + if (friendly_name) { + friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`; + } + axios .put( - `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`, + `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`, { requires_restart: 0, update_topic: `config/cameras/${polygon.camera}/zones`, @@ -427,7 +457,7 @@ export default function ZoneEditPane({ if (res.status === 200) { toast.success( t("masksAndZones.zones.toast.success", { - zoneName, + zoneName: friendly_name || zoneName, }), { position: "top-center", @@ -541,26 +571,16 @@ export default function ZoneEditPane({
- ( - - {t("masksAndZones.zones.name.title")} - - - - - {t("masksAndZones.zones.name.tips")} - - - - )} + nameField="friendly_name" + idField="name" + nameLabel={t("masksAndZones.zones.name.title")} + nameDescription={t("masksAndZones.zones.name.tips")} + placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")} /> + ("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 19fd63f5c9..918d492a33 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, @@ -650,7 +650,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 9c1748ce09..a702388344 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -11,10 +11,12 @@ export type Polygon = { distances: number[]; isFinished: boolean; color: number[]; + friendly_name?: string; }; export type ZoneFormValuesType = { name: string; + friendly_name: string; inertia: number; loitering_time: number; isFinished: boolean; diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index f105633798..c7cbb50b80 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -280,6 +280,7 @@ export interface CameraConfig { speed_threshold: number; objects: string[]; color: number[]; + friendly_name?: string; }; }; } diff --git a/web/src/types/timeline.ts b/web/src/types/timeline.ts index 4551937d99..c8e5f7543a 100644 --- a/web/src/types/timeline.ts +++ b/web/src/types/timeline.ts @@ -22,6 +22,7 @@ export type TrackingDetailsSequence = { attribute: string; attribute_box?: [number, number, number, number]; zones: string[]; + zones_friendly_names?: string[]; }; class_type: LifecycleClassType; source_id: string; diff --git a/web/src/utils/lifecycleUtil.ts b/web/src/utils/lifecycleUtil.ts index d672445ff7..fa9ce92818 100644 --- a/web/src/utils/lifecycleUtil.ts +++ b/web/src/utils/lifecycleUtil.ts @@ -42,7 +42,9 @@ export function getLifecycleItemDescription( return t("trackingDetails.lifecycleItemDesc.entered_zone", { ns: "views/explore", label, - zones: formatZonesList(lifecycleItem.data.zones), + zones: formatZonesList( + lifecycleItem.data.zones_friendly_names ?? lifecycleItem.data.zones, + ), }); case "active": return t("trackingDetails.lifecycleItemDesc.active", { diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 0c13876e8a..7192d2618f 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -63,7 +63,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 db5f225559..5c11d89143 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 4db384e0ad..52d5879e14 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 dae7c33650..7e27aaebb6 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -23,7 +23,6 @@ import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import axios from "axios"; import { Link } from "react-router-dom"; import { LuExternalLink } from "react-icons/lu"; -import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { MdCircle } from "react-icons/md"; import { cn } from "@/lib/utils"; import { Trans, useTranslation } from "react-i18next"; @@ -42,6 +41,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 +86,23 @@ export default function CameraSettingsView({ // zones and labels + const getZoneName = useCallback( + (zoneId: string, cameraId?: 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(name, cameraConfig.name), objects: zoneData.objects, color: zoneData.color, })); } - }, [cameraConfig]); + }, [cameraConfig, getZoneName]); const alertsLabels = useMemo(() => { return cameraConfig?.review.alerts.labels @@ -526,7 +533,7 @@ export default function CameraSettingsView({ /> - {zone.name.replaceAll("_", " ")} + {zone.friendly_name} )} @@ -548,14 +555,9 @@ export default function CameraSettingsView({ "cameraReview.reviewClassification.zoneObjectAlertsTips", { alertsLabels, - zone: watchedAlertsZones - .map((zone) => - capitalizeFirstLetter(zone).replaceAll( - "_", - " ", - ), - ) - .join(", "), + zone: watchedAlertsZones.map((zone) => + getZoneName(zone), + ), cameraName: selectCameraName, }, ) @@ -628,7 +630,7 @@ export default function CameraSettingsView({ /> - {zone.name.replaceAll("_", " ")} + {zone.friendly_name} )} @@ -667,14 +669,9 @@ export default function CameraSettingsView({ i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text" values={{ detectionsLabels, - zone: watchedDetectionsZones - .map((zone) => - capitalizeFirstLetter(zone).replaceAll( - "_", - " ", - ), - ) - .join(", "), + zone: watchedDetectionsZones.map((zone) => + getZoneName(zone), + ), cameraName: selectCameraName, }} ns="views/settings" @@ -684,14 +681,9 @@ export default function CameraSettingsView({ i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections" values={{ detectionsLabels, - zone: watchedDetectionsZones - .map((zone) => - capitalizeFirstLetter(zone).replaceAll( - "_", - " ", - ), - ) - .join(", "), + zone: watchedDetectionsZones.map((zone) => + getZoneName(zone), + ), cameraName: selectCameraName, }} ns="views/settings" diff --git a/web/src/views/settings/FrigatePlusSettingsView.tsx b/web/src/views/settings/FrigatePlusSettingsView.tsx index 20e2480709..52af94354d 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 956ce3f95e..27c542e875 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, + friendly_name: 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 b3a0494180..6280ca6a88 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({