mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-15 00:11:15 +03:00
Add configurable notification suspend durations
This commit is contained in:
parent
f0a6626c6a
commit
80446756a6
@ -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",
|
||||
|
||||
@ -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)
|
||||
),
|
||||
)
|
||||
|
||||
82
frigate/test/test_notification.py
Normal file
82
frigate/test/test_notification.py
Normal 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()
|
||||
@ -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"
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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" });
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user