Add configurable notification suspend durations

This commit is contained in:
Dmitry Marchuk 2026-04-04 08:15:32 +03:00
parent f0a6626c6a
commit 80446756a6
9 changed files with 200 additions and 114 deletions

View File

@ -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",

View File

@ -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)
),
)

View File

@ -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()

View File

@ -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"

View File

@ -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 },

View File

@ -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({
<SelectValue placeholder={t("notification.suspendTime.suspend")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">
{t("notification.suspendTime.5minutes")}
</SelectItem>
<SelectItem value="10">
{t("notification.suspendTime.10minutes")}
</SelectItem>
<SelectItem value="30">
{t("notification.suspendTime.30minutes")}
</SelectItem>
<SelectItem value="60">
{t("notification.suspendTime.1hour")}
</SelectItem>
<SelectItem value="840">
{t("notification.suspendTime.12hours")}
</SelectItem>
<SelectItem value="1440">
{t("notification.suspendTime.24hours")}
</SelectItem>
<SelectItem value="off">
{t("notification.suspendTime.untilRestart")}
</SelectItem>
{(config?.notifications.suspend_durations ?? []).map((duration) => {
const label = formatSuspendDuration(duration, t);
return (
<SelectItem key={duration} value={duration}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
) : (

View File

@ -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")}
</p>
<div className="space-y-1">
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled ? () => handleSuspend("5") : undefined
}
>
{t("time.5minutes", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("10")
: undefined
}
>
{t("time.10minutes", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("30")
: undefined
}
>
{t("time.30minutes", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("60")
: undefined
}
>
{t("time.1hour", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("840")
: undefined
}
>
{t("time.12hours", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("1440")
: undefined
}
>
{t("time.24hours", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("off")
: undefined
}
>
{t("time.untilRestart", { ns: "common" })}
</ContextMenuItem>
{(
config?.notifications.suspend_durations ?? []
).map((duration) => {
const label = formatSuspendDuration(duration, t);
return (
<ContextMenuItem
key={duration}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend(duration)
: undefined
}
>
{label}
</ContextMenuItem>
);
})}
</div>
</div>
</>

View File

@ -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: {

View File

@ -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<string, string> = {
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" });
}