From 80446756a6255f08b1092671764d60e0c4e5a338 Mon Sep 17 00:00:00 2001 From: Dmitry Marchuk Date: Sat, 4 Apr 2026 08:15:32 +0300 Subject: [PATCH] Add configurable notification suspend durations --- frigate/comms/dispatcher.py | 21 ++-- frigate/config/camera/notification.py | 44 ++++++++- frigate/test/test_notification.py | 82 ++++++++++++++++ web/public/locales/en/common.json | 2 + web/src/api/ws.ts | 2 +- .../NotificationsSettingsExtras.tsx | 41 +++----- web/src/components/menu/LiveContextMenu.tsx | 98 +++++-------------- web/src/types/frigateConfig.ts | 2 + web/src/utils/dateUtil.ts | 22 +++++ 9 files changed, 200 insertions(+), 114 deletions(-) create mode 100644 frigate/test/test_notification.py diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 4eeb763961..f0ac916eb1 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -10,6 +10,7 @@ from frigate.camera.activity_manager import AudioActivityManager, CameraActivity from frigate.comms.base_communicator import Communicator from frigate.comms.webpush import WebPushClient from frigate.config import BirdseyeModeEnum, FrigateConfig +from frigate.config.camera.notification import parse_duration_to_minutes from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdatePublisher, @@ -784,11 +785,6 @@ class Dispatcher: def _on_camera_notification_suspend(self, camera_name: str, payload: str) -> None: """Callback for camera level notifications suspend topic.""" - try: - duration = int(payload) - except ValueError: - logger.error(f"Invalid suspension duration: {payload}") - return if self.web_push_client is None: logger.error("WebPushClient not available for suspension") @@ -800,10 +796,17 @@ class Dispatcher: logger.error(f"Notifications are not enabled for {camera_name}") return - if duration != 0: - self.web_push_client.suspend_notifications(camera_name, duration) - else: - self.web_push_client.unsuspend_notifications(camera_name) + if payload == "until_restart": + self._on_camera_notification_command(camera_name, "OFF") + return + + try: + duration = parse_duration_to_minutes(payload) + except ValueError: + logger.error(f"Invalid suspension duration: {payload}") + return + + self.web_push_client.suspend_notifications(camera_name, duration) self.publish( f"{camera_name}/notifications/suspended", diff --git a/frigate/config/camera/notification.py b/frigate/config/camera/notification.py index dabf94675c..7538b111ca 100644 --- a/frigate/config/camera/notification.py +++ b/frigate/config/camera/notification.py @@ -1,11 +1,31 @@ +import re from typing import Optional -from pydantic import Field +from pydantic import Field, field_validator from ..base import FrigateBaseModel __all__ = ["NotificationConfig"] +DURATION_PATTERN = re.compile(r"^(\d+)\s*([mhdw])$") +UNIT_TO_MINUTES = {"m": 1, "h": 60, "d": 1440, "w": 10080} + +DEFAULT_SUSPEND_DURATIONS = ["5m", "10m", "30m", "1h", "12h", "24h", "until_restart"] + + +def parse_duration_to_minutes(value: str) -> int: + """Parse a duration string like '5m', '24h' into total minutes.""" + match = DURATION_PATTERN.match(value.strip().lower()) + if not match: + raise ValueError( + f"Invalid duration format: '{value}'. Use number + unit (m/h/d/w), e.g. '5m', '24h', '7d'." + ) + count = int(match.group(1)) + unit = match.group(2) + if count <= 0: + raise ValueError(f"Duration must be positive: '{value}'") + return count * UNIT_TO_MINUTES[unit] + class NotificationConfig(FrigateBaseModel): enabled: bool = Field( @@ -29,3 +49,25 @@ class NotificationConfig(FrigateBaseModel): title="Original notifications state", description="Indicates whether notifications were enabled in the original static configuration.", ) + suspend_durations: list[str] = Field( + default=DEFAULT_SUSPEND_DURATIONS, + title="Suspend duration options", + description="List of duration options for the notification suspend dropdown. Use format: number + unit (m/h/d/w). Special value 'until_restart' suspends until Frigate restarts.", + ) + + @field_validator("suspend_durations") + @classmethod + def validate_suspend_durations(cls, v: list[str]) -> list[str]: + if not v: + raise ValueError("suspend_durations must not be empty") + for entry in v: + if entry == "until_restart": + continue + parse_duration_to_minutes(entry) + # Sort by duration, with "until_restart" always last + return sorted( + v, + key=lambda x: ( + float("inf") if x == "until_restart" else parse_duration_to_minutes(x) + ), + ) diff --git a/frigate/test/test_notification.py b/frigate/test/test_notification.py new file mode 100644 index 0000000000..31390b6dc2 --- /dev/null +++ b/frigate/test/test_notification.py @@ -0,0 +1,82 @@ +import unittest + +from pydantic import ValidationError + +from frigate.config.camera.notification import ( + NotificationConfig, + parse_duration_to_minutes, +) + + +class TestParseDurationToMinutes(unittest.TestCase): + def test_minutes(self): + self.assertEqual(parse_duration_to_minutes("5m"), 5) + self.assertEqual(parse_duration_to_minutes("1m"), 1) + self.assertEqual(parse_duration_to_minutes("120m"), 120) + + def test_hours(self): + self.assertEqual(parse_duration_to_minutes("1h"), 60) + self.assertEqual(parse_duration_to_minutes("24h"), 24 * 60) + + def test_days(self): + self.assertEqual(parse_duration_to_minutes("1d"), 24 * 60) + self.assertEqual(parse_duration_to_minutes("7d"), 7 * 24 * 60) + + def test_weeks(self): + self.assertEqual(parse_duration_to_minutes("1w"), 7 * 24 * 60) + self.assertEqual(parse_duration_to_minutes("2w"), 2 * 7 * 24 * 60) + + def test_case_insensitive(self): + self.assertEqual(parse_duration_to_minutes("5M"), 5) + self.assertEqual(parse_duration_to_minutes("1H"), 60) + + def test_whitespace_stripped(self): + self.assertEqual(parse_duration_to_minutes(" 5m "), 5) + self.assertEqual(parse_duration_to_minutes(" 1h"), 60) + + def test_invalid_format(self): + with self.assertRaises(ValueError): + parse_duration_to_minutes("abc") + with self.assertRaises(ValueError): + parse_duration_to_minutes("5x") + with self.assertRaises(ValueError): + parse_duration_to_minutes("m5") + with self.assertRaises(ValueError): + parse_duration_to_minutes("") + + def test_zero_rejected_by_regex(self): + with self.assertRaises(ValueError): + parse_duration_to_minutes("0h") + + def test_plain_zero_rejected(self): + with self.assertRaises(ValueError): + parse_duration_to_minutes("0") + + +class TestNotificationConfigSuspendDurations(unittest.TestCase): + def test_default_durations(self): + config = NotificationConfig() + self.assertEqual( + config.suspend_durations, + ["5m", "10m", "30m", "1h", "12h", "24h", "until_restart"], + ) + + def test_empty_list_rejected(self): + with self.assertRaises(ValidationError): + NotificationConfig(suspend_durations=[]) + + def test_invalid_duration_rejected(self): + with self.assertRaises(ValidationError): + NotificationConfig(suspend_durations=["5m", "10m", "invalid"]) + + def test_durations_sorted_ascending(self): + config = NotificationConfig(suspend_durations=["1h", "5m", "24h", "10m"]) + self.assertEqual(config.suspend_durations, ["5m", "10m", "1h", "24h"]) + + def test_until_restart_always_last(self): + config = NotificationConfig(suspend_durations=["until_restart", "1h", "5m"]) + self.assertEqual(config.suspend_durations, ["5m", "1h", "until_restart"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index de17f444b5..abee0629c4 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -41,6 +41,8 @@ "s": "{{time}}s", "second_one": "{{time}} second", "second_other": "{{time}} seconds", + "week_one": "{{time}} week", + "week_other": "{{time}} weeks", "formattedTimestamp": { "12hour": "MMM d, h:mm:ss aaa", "24hour": "MMM d, HH:mm:ss" diff --git a/web/src/api/ws.ts b/web/src/api/ws.ts index 845ad1fa63..affffb0761 100644 --- a/web/src/api/ws.ts +++ b/web/src/api/ws.ts @@ -774,7 +774,7 @@ export function useNotifications(camera: string): { export function useNotificationSuspend(camera: string): { payload: string; - send: (payload: number, retain?: boolean) => void; + send: (payload: string, retain?: boolean) => void; } { const { value: { payload }, diff --git a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx index b97c90448d..6c62c570d5 100644 --- a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx +++ b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx @@ -42,7 +42,10 @@ import { SelectContent, SelectItem, } from "@/components/ui/select"; -import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { + formatSuspendDuration, + formatUnixTimestampToDateTime, +} from "@/utils/dateUtil"; import { use24HourTime } from "@/hooks/use-date-utils"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -740,16 +743,11 @@ export function CameraNotificationSwitch({ const handleSuspend = (duration: string) => { setIsSuspended(true); - if (duration == "off") { - sendNotification("OFF"); - } else { - sendNotificationSuspend(parseInt(duration)); - } + sendNotificationSuspend(duration); }; const handleCancelSuspension = () => { sendNotification("ON"); - sendNotificationSuspend(0); }; const locale = useDateLocale(); @@ -811,27 +809,14 @@ export function CameraNotificationSwitch({ - - {t("notification.suspendTime.5minutes")} - - - {t("notification.suspendTime.10minutes")} - - - {t("notification.suspendTime.30minutes")} - - - {t("notification.suspendTime.1hour")} - - - {t("notification.suspendTime.12hours")} - - - {t("notification.suspendTime.24hours")} - - - {t("notification.suspendTime.untilRestart")} - + {(config?.notifications.suspend_durations ?? []).map((duration) => { + const label = formatSuspendDuration(duration, t); + return ( + + {label} + + ); + })} ) : ( diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index 8ed78e348c..dc55d67a01 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -38,7 +38,10 @@ import { } from "react-icons/io"; import { cn } from "@/lib/utils"; import { useNavigate } from "react-router-dom"; -import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { + formatSuspendDuration, + formatUnixTimestampToDateTime, +} from "@/utils/dateUtil"; import { useEnabledState, useNotifications, @@ -239,11 +242,7 @@ export default function LiveContextMenu({ }, [notificationSuspendUntil, notificationState]); const handleSuspend = (duration: string) => { - if (duration === "off") { - sendNotification("OFF"); - } else { - sendNotificationSuspend(Number.parseInt(duration)); - } + sendNotificationSuspend(duration); }; const locale = useDateLocale(); @@ -451,7 +450,6 @@ export default function LiveContextMenu({ isEnabled ? () => { sendNotification("ON"); - sendNotificationSuspend(0); } : undefined } @@ -476,74 +474,24 @@ export default function LiveContextMenu({ {t("suspend.forTime")}

- handleSuspend("5") : undefined - } - > - {t("time.5minutes", { ns: "common" })} - - handleSuspend("10") - : undefined - } - > - {t("time.10minutes", { ns: "common" })} - - handleSuspend("30") - : undefined - } - > - {t("time.30minutes", { ns: "common" })} - - handleSuspend("60") - : undefined - } - > - {t("time.1hour", { ns: "common" })} - - handleSuspend("840") - : undefined - } - > - {t("time.12hours", { ns: "common" })} - - handleSuspend("1440") - : undefined - } - > - {t("time.24hours", { ns: "common" })} - - handleSuspend("off") - : undefined - } - > - {t("time.untilRestart", { ns: "common" })} - + {( + config?.notifications.suspend_durations ?? [] + ).map((duration) => { + const label = formatSuspendDuration(duration, t); + return ( + handleSuspend(duration) + : undefined + } + > + {label} + + ); + })}
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 9a745b76c9..b9e8e74536 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -142,6 +142,7 @@ export interface CameraConfig { enabled: boolean; email?: string; enabled_in_config: boolean; + suspend_durations?: string[]; }; objects: { filters: { @@ -549,6 +550,7 @@ export interface FrigateConfig { enabled: boolean; email?: string; enabled_in_config: boolean; + suspend_durations?: string[]; }; objects: { diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts index cc541214c2..439f21d01c 100644 --- a/web/src/utils/dateUtil.ts +++ b/web/src/utils/dateUtil.ts @@ -1,6 +1,7 @@ import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns"; import { enUS, Locale } from "date-fns/locale"; import { formatInTimeZone } from "date-fns-tz"; +import { TFunction } from "i18next"; import i18n from "@/utils/i18n"; export const longToDate = (long: number): Date => new Date(long * 1000); export const epochToLong = (date: number): number => date / 1000; @@ -533,3 +534,24 @@ export function convertTo12Hour(time: string) { const hour12 = hour % 12 || 12; return `${hour12}:${minutes} ${ampm}`; } + +const UNIT_SUFFIX_TO_LABEL: Record = { + m: "minute", + h: "hour", + d: "day", + w: "week", +}; + +export function formatSuspendDuration(duration: string, t: TFunction): string { + if (duration === "until_restart") { + return t("time.untilRestart", { ns: "common" }); + } + const match = duration.match(/^(\d+)\s*([mhdw])$/); + if (!match) { + return duration; + } + const count = parseInt(match[1], 10); + const unit = UNIT_SUFFIX_TO_LABEL[match[2]]; + const plural = count === 1 ? "one" : "other"; + return t(`time.${unit}_${plural}`, { time: count, ns: "common" }); +}