From 5059311c9de262c97f863514dc9d003d0e42e654 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:15:51 -0500 Subject: [PATCH] Mask/zone editor fixes (#22732) * add guards to reject missing sub commands * mask/zone bugfixes - fix websocket crash when creating a new mask or zone before a name is assigned - fix deleted masks and zones not disappearing from the list until navigating away - fix deleting profile override not reverting to the base mask in the list - fix inertia defaulting to nan * disable save button on invalid form state * fix validation for speed estimation * ensure polygon is closed before allowing save * require all masks and zones to be on the base config * clarify dialog message and tooltip when removing an override * clarify docs --- docs/docs/configuration/profiles.md | 2 +- frigate/api/camera.py | 9 ++ frigate/comms/dispatcher.py | 11 ++ web/public/locales/en/views/settings.json | 5 + .../settings/MotionMaskEditPane.tsx | 21 +++- .../settings/ObjectMaskEditPane.tsx | 18 ++- web/src/components/settings/PolygonItem.tsx | 110 ++++++++++++++---- web/src/components/settings/ZoneEditPane.tsx | 45 ++++--- web/src/views/settings/MasksAndZonesView.tsx | 100 ++++++++++------ 9 files changed, 235 insertions(+), 86 deletions(-) diff --git a/docs/docs/configuration/profiles.md b/docs/docs/configuration/profiles.md index c37fca7db..b290d30f7 100644 --- a/docs/docs/configuration/profiles.md +++ b/docs/docs/configuration/profiles.md @@ -120,7 +120,7 @@ The following camera configuration sections can be overridden in a profile: :::note -Only the fields you explicitly set in a profile override are applied. All other fields retain their base configuration values. For zones, profile zones are merged with the camera's base zones — any zone defined in the profile will override or add to the base zones. +Only the fields you explicitly set in a profile override are applied. All other fields retain their base configuration values. For masks and zones, profile zones **override** the camera's base masks and zones. If configuring profiles via YAML, you should not define masks or zones in profiles that are not defined in the base config. ::: diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 1c03a5b7a..1881bde6d 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -1224,6 +1224,15 @@ def camera_set( status_code=400, ) + if not sub_command and feature in _SUB_COMMAND_FEATURES: + return JSONResponse( + content={ + "success": False, + "message": f"Feature '{feature}' requires a sub-command (e.g. mask or zone name)", + }, + status_code=400, + ) + if camera_name == "*": cameras = list(frigate_config.cameras.keys()) elif camera_name not in frigate_config.cameras: diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 4eeb76396..27d5ef125 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -118,10 +118,21 @@ class Dispatcher: try: if command_type == "set": + # Commands that require a sub-command (mask/zone name) + sub_command_required = { + "motion_mask", + "object_mask", + "zone", + } if sub_command: self._camera_settings_handlers[command]( camera_name, sub_command, payload ) + elif command in sub_command_required: + logger.error( + "Command %s requires a sub-command (mask/zone name)", + command, + ) else: self._camera_settings_handlers[command](camera_name, payload) elif command_type == "ptz": diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 18c277cfc..660821c9c 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -539,6 +539,7 @@ }, "restart_required": "Restart required (masks/zones changed)", "disabledInConfig": "Item is disabled in the config file", + "addDisabledProfile": "Add to the base config first, then override in the profile", "profileBase": "(base)", "profileOverride": "(override)", "toast": { @@ -613,6 +614,10 @@ "desc": "Are you sure you want to delete the {{type}} {{name}}?", "success": "{{name}} has been deleted." }, + "revertOverride": { + "title": "Revert to Base Config", + "desc": "This will remove the profile override for the {{type}} {{name}} and revert to the base configuration." + }, "error": { "mustBeFinished": "Polygon drawing must be finished before saving." } diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx index 1f4aaf76f..4fa09837c 100644 --- a/web/src/components/settings/MotionMaskEditPane.tsx +++ b/web/src/components/settings/MotionMaskEditPane.tsx @@ -74,9 +74,11 @@ export default function MotionMaskEditPane({ } }, [polygons, activePolygonIndex]); + const maskCamera = polygon?.camera || ""; + const maskName = polygon?.name || ""; const { send: sendMotionMaskState } = useMotionMaskState( - polygon?.camera || "", - polygon?.name || "", + maskCamera, + maskName, ); const cameraConfig = useMemo(() => { @@ -154,7 +156,7 @@ export default function MotionMaskEditPane({ message: t("masksAndZones.form.name.error.mustNotBeEmpty"), }), enabled: z.boolean(), - isFinished: z.boolean().refine(() => polygon?.isFinished === true, { + isFinished: z.boolean().refine((val) => val === true, { message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"), }), }); @@ -170,6 +172,12 @@ export default function MotionMaskEditPane({ }, }); + useEffect(() => { + if (polygon?.isFinished !== undefined) { + form.setValue("isFinished", polygon.isFinished, { shouldValidate: true }); + } + }, [polygon?.isFinished, form]); + const saveToConfig = useCallback( async ({ name: maskId, @@ -250,8 +258,8 @@ export default function MotionMaskEditPane({ }, ); updateConfig(); - // Only publish WS state for base config - if (!editingProfile) { + // Only publish WS state for base config when mask has a name + if (!editingProfile && maskName) { sendMotionMaskState(enabled ? "ON" : "OFF"); } } else { @@ -291,6 +299,7 @@ export default function MotionMaskEditPane({ cameraConfig, t, sendMotionMaskState, + maskName, editingProfile, ], ); @@ -449,7 +458,7 @@ export default function MotionMaskEditPane({ + + + - {t("masksAndZones.zones.add")} + {currentEditingProfile + ? t("masksAndZones.addDisabledProfile") + : t("masksAndZones.zones.add")} @@ -843,6 +858,7 @@ export default function MasksAndZonesView({ setLoadingPolygonIndex={setLoadingPolygonIndex} editingProfile={currentEditingProfile} allProfileNames={profileState?.allProfileNames} + onDeleted={handlePolygonDeleted} /> ))} @@ -880,20 +896,25 @@ export default function MasksAndZonesView({ - + + + - {t("masksAndZones.motionMasks.add")} + {currentEditingProfile + ? t("masksAndZones.addDisabledProfile") + : t("masksAndZones.motionMasks.add")} @@ -919,6 +940,7 @@ export default function MasksAndZonesView({ setLoadingPolygonIndex={setLoadingPolygonIndex} editingProfile={currentEditingProfile} allProfileNames={profileState?.allProfileNames} + onDeleted={handlePolygonDeleted} /> ))} @@ -956,20 +978,25 @@ export default function MasksAndZonesView({ - + + + - {t("masksAndZones.objectMasks.add")} + {currentEditingProfile + ? t("masksAndZones.addDisabledProfile") + : t("masksAndZones.objectMasks.add")} @@ -995,6 +1022,7 @@ export default function MasksAndZonesView({ setLoadingPolygonIndex={setLoadingPolygonIndex} editingProfile={currentEditingProfile} allProfileNames={profileState?.allProfileNames} + onDeleted={handlePolygonDeleted} /> ))}