diff --git a/web/src/components/settings/PolygonCanvas.tsx b/web/src/components/settings/PolygonCanvas.tsx index cee0952c1..30b203bd3 100644 --- a/web/src/components/settings/PolygonCanvas.tsx +++ b/web/src/components/settings/PolygonCanvas.tsx @@ -7,6 +7,7 @@ import { Polygon, PolygonType } from "@/types/canvas"; import { useApiHost } from "@/api"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { snapPointToLines } from "@/utils/canvasUtil"; +import { usePolygonStates } from "@/hooks/use-polygon-states"; type PolygonCanvasProps = { containerRef: RefObject; @@ -40,6 +41,7 @@ export function PolygonCanvas({ const imageRef = useRef(null); const stageRef = useRef(null); const apiHost = useApiHost(); + const getPolygonEnabled = usePolygonStates(polygons); const videoElement = useMemo(() => { if (camera && width && height) { @@ -321,7 +323,7 @@ export function PolygonCanvas({ isActive={index === activePolygonIndex} isHovered={index === hoveredPolygonIndex} isFinished={polygon.isFinished} - enabled={polygon.enabled} + enabled={getPolygonEnabled(polygon)} color={polygon.color} handlePointDragMove={handlePointDragMove} handleGroupDragEnd={handleGroupDragEnd} @@ -351,7 +353,7 @@ export function PolygonCanvas({ isActive={true} isHovered={activePolygonIndex === hoveredPolygonIndex} isFinished={polygons[activePolygonIndex].isFinished} - enabled={polygons[activePolygonIndex].enabled} + enabled={getPolygonEnabled(polygons[activePolygonIndex])} color={polygons[activePolygonIndex].color} handlePointDragMove={handlePointDragMove} handleGroupDragEnd={handleGroupDragEnd} diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index d72c06076..c793cf6bb 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -34,6 +34,7 @@ import { buttonVariants } from "../ui/button"; import { Trans, useTranslation } from "react-i18next"; import ActivityIndicator from "../indicators/activity-indicator"; import { cn } from "@/lib/utils"; +import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws"; type PolygonItemProps = { polygon: Polygon; @@ -66,6 +67,31 @@ export default function PolygonItem({ const { data: config, mutate: updateConfig } = useSWR("config"); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const { payload: motionMaskState, send: sendMotionMaskState } = + useMotionMaskState(polygon.camera, polygon.name); + const { payload: objectMaskState, send: sendObjectMaskState } = + useObjectMaskState(polygon.camera, polygon.name); + const { payload: zoneState, send: sendZoneState } = useZoneState( + polygon.camera, + polygon.name, + ); + const isPolygonEnabled = useMemo(() => { + const wsState = + polygon.type === "zone" + ? zoneState + : polygon.type === "motion_mask" + ? motionMaskState + : objectMaskState; + const wsEnabled = + wsState === "ON" ? true : wsState === "OFF" ? false : undefined; + return wsEnabled ?? polygon.enabled ?? true; + }, [ + polygon.enabled, + polygon.type, + zoneState, + motionMaskState, + objectMaskState, + ]); const cameraConfig = useMemo(() => { if (polygon?.camera && config) { @@ -261,164 +287,35 @@ export default function PolygonItem({ }; const handleToggleEnabled = useCallback( - async (e: React.MouseEvent) => { + (e: React.MouseEvent) => { e.stopPropagation(); - if (!polygon || !cameraConfig) { + if (!polygon) { return; } - const newEnabledState = polygon.enabled === false; - const updateTopicType = - polygon.type === "zone" - ? "zones" - : polygon.type === "motion_mask" - ? "motion" - : polygon.type === "object_mask" - ? "objects" - : polygon.type; - - setIsLoading(true); - setLoadingPolygonIndex(index); + const isEnabled = isPolygonEnabled; + const nextState = isEnabled ? "OFF" : "ON"; if (polygon.type === "zone") { - // Zones use query string format - const url = `cameras.${polygon.camera}.zones.${polygon.name}.enabled=${newEnabledState ? "True" : "False"}`; - - await axios - .put(`config/set?${url}`, { - requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, - }) - .then((res) => { - if (res.status === 200) { - updateConfig(); - } else { - toast.error( - t("toast.save.error.title", { - ns: "common", - errorMessage: res.statusText, - }), - { position: "top-center" }, - ); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error( - t("toast.save.error.title", { errorMessage, ns: "common" }), - { position: "top-center" }, - ); - }) - .finally(() => { - setIsLoading(false); - }); + sendZoneState(nextState); return; } - // Motion masks and object masks use JSON body format - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let configUpdate: any = {}; - if (polygon.type === "motion_mask") { - configUpdate = { - cameras: { - [polygon.camera]: { - motion: { - mask: { - [polygon.name]: { - enabled: newEnabledState, - }, - }, - }, - }, - }, - }; + sendMotionMaskState(nextState); + return; } if (polygon.type === "object_mask") { - // Determine if this is a global mask or object-specific mask - const isGlobalMask = !polygon.objects.length; - - if (isGlobalMask) { - configUpdate = { - cameras: { - [polygon.camera]: { - objects: { - mask: { - [polygon.name]: { - enabled: newEnabledState, - }, - }, - }, - }, - }, - }; - } else { - configUpdate = { - cameras: { - [polygon.camera]: { - objects: { - filters: { - [polygon.objects[0]]: { - mask: { - [polygon.name]: { - enabled: newEnabledState, - }, - }, - }, - }, - }, - }, - }, - }; - } + sendObjectMaskState(nextState); } - - await axios - .put("config/set", { - config_data: configUpdate, - requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, - }) - .then((res) => { - if (res.status === 200) { - updateConfig(); - } else { - toast.error( - t("toast.save.error.title", { - ns: "common", - errorMessage: res.statusText, - }), - { position: "top-center" }, - ); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error( - t("toast.save.error.title", { errorMessage, ns: "common" }), - { position: "top-center" }, - ); - }) - .finally(() => { - setIsLoading(false); - setLoadingPolygonIndex(undefined); - }); }, [ - updateConfig, - cameraConfig, - t, + isPolygonEnabled, polygon, - setIsLoading, - index, - setLoadingPolygonIndex, + sendZoneState, + sendMotionMaskState, + sendObjectMaskState, ], ); @@ -463,30 +360,27 @@ export default function PolygonItem({ - {polygon.enabled === false - ? t("button.enable", { ns: "common" }) - : t("button.disable", { ns: "common" })} + {isPolygonEnabled + ? t("button.disable", { ns: "common" }) + : t("button.enable", { ns: "common" })} ))}

{polygon.friendly_name ?? polygon.name} - {polygon.enabled === false && " (disabled)"} + {!isPolygonEnabled && " (disabled)"}

{ + const stateMap = new Map(); + + polygons.forEach((polygon) => { + const topic = + polygon.type === "zone" + ? `${polygon.camera}/zone/${polygon.name}/state` + : polygon.type === "motion_mask" + ? `${polygon.camera}/motion_mask/${polygon.name}/state` + : `${polygon.camera}/object_mask/${polygon.name}/state`; + + const wsValue = wsState[topic]; + const enabled = + wsValue === "ON" + ? true + : wsValue === "OFF" + ? false + : (polygon.enabled ?? true); + stateMap.set( + `${polygon.camera}/${polygon.type}/${polygon.name}`, + enabled, + ); + }); + + return (polygon: Polygon) => { + return ( + stateMap.get(`${polygon.camera}/${polygon.type}/${polygon.name}`) ?? + true + ); + }; + }, [polygons, wsState]); +} diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index 5fa932dbb..c8ac2f607 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -300,7 +300,6 @@ export default function MasksAndZonesView({ }), ); - const globalObjectMaskIds = Object.keys(cameraConfig.objects.mask || {}); let objectMaskIndex = globalObjectMasks.length; objectMasks = Object.entries(cameraConfig.objects.filters) @@ -311,8 +310,8 @@ export default function MasksAndZonesView({ .flatMap(([objectName, filterConfig]): Polygon[] => { return Object.entries(filterConfig.mask || {}).flatMap( ([maskId, maskData]) => { - // Skip if this mask is already included in global masks - if (globalObjectMaskIds.includes(maskId)) { + // Skip if this mask is a global mask (prefixed with "global_") + if (maskId.startsWith("global_")) { return []; }