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({