diff --git a/.cspell/frigate-dictionary.txt b/.cspell/frigate-dictionary.txt index cc6adcc02..dbab9600e 100644 --- a/.cspell/frigate-dictionary.txt +++ b/.cspell/frigate-dictionary.txt @@ -44,6 +44,7 @@ codeproject colormap colorspace comms +cooldown coro ctypeslib CUDA diff --git a/docs/docs/configuration/notifications.md b/docs/docs/configuration/notifications.md index 9225ea6e8..8ae2f6d47 100644 --- a/docs/docs/configuration/notifications.md +++ b/docs/docs/configuration/notifications.md @@ -11,14 +11,37 @@ Frigate offers native notifications using the [WebPush Protocol](https://web.dev In order to use notifications the following requirements must be met: -- Frigate must be accessed via a secure https connection +- Frigate must be accessed via a secure `https` connection ([see the authorization docs](/configuration/authentication)). - A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported. -- In order for notifications to be usable externally, Frigate must be accessible externally +- In order for notifications to be usable externally, Frigate must be accessible externally. ### Configuration To configure notifications, go to the Frigate WebUI -> Settings -> Notifications and enable, then fill out the fields and save. +Optionally, you can change the default cooldown period for notifications through the `cooldown` parameter in your config file. This parameter can also be overridden at the camera level. + +Notifications will be prevented if either: + +- The global cooldown period hasn't elapsed since any camera's last notification +- The camera-specific cooldown period hasn't elapsed for the specific camera + +```yaml +notifications: + enabled: True + email: "johndoe@gmail.com" + cooldown: 10 # wait 10 seconds before sending another notification from any camera +``` + +```yaml +cameras: + doorbell: + ... + notifications: + enabled: True + cooldown: 30 # wait 30 seconds before sending another notification from the doorbell camera +``` + ### Registration Once notifications are enabled, press the `Register for Notifications` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent. @@ -39,4 +62,4 @@ Different platforms handle notifications differently, some settings changes may ### Android -Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well. \ No newline at end of file +Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well. diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 21b60f449..b791e708a 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -420,6 +420,8 @@ notifications: # Optional: Email for push service to reach out to # NOTE: This is required to use notifications email: "admin@example.com" + # Optional: Cooldown time for notifications in seconds (default: shown below) + cooldown: 0 # Optional: Record configuration # NOTE: Can be overridden at the camera level diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py index b55b7e82c..b845c3afd 100644 --- a/frigate/comms/webpush.py +++ b/frigate/comms/webpush.py @@ -47,6 +47,10 @@ class WebPushClient(Communicator): # type: ignore[misc] self.suspended_cameras: dict[str, int] = { c.name: 0 for c in self.config.cameras.values() } + self.last_camera_notification_time: dict[str, float] = { + c.name: 0 for c in self.config.cameras.values() + } + self.last_notification_time: float = 0 self.notification_queue: queue.Queue[PushNotification] = queue.Queue() self.notification_thread = threading.Thread( target=self._process_notifications, daemon=True @@ -264,6 +268,29 @@ class WebPushClient(Communicator): # type: ignore[misc] ): return + camera: str = payload["after"]["camera"] + current_time = datetime.datetime.now().timestamp() + + # Check global cooldown period + if ( + current_time - self.last_notification_time + < self.config.notifications.cooldown + ): + logger.debug( + f"Skipping notification for {camera} - in global cooldown period" + ) + return + + # Check camera-specific cooldown period + if ( + current_time - self.last_camera_notification_time[camera] + < self.config.cameras[camera].notifications.cooldown + ): + logger.debug( + f"Skipping notification for {camera} - in camera-specific cooldown period" + ) + return + self.check_registrations() state = payload["type"] @@ -278,6 +305,9 @@ class WebPushClient(Communicator): # type: ignore[misc] ): return + self.last_camera_notification_time[camera] = current_time + self.last_notification_time = current_time + reviewId = payload["after"]["id"] sorted_objects: set[str] = set() @@ -287,7 +317,6 @@ class WebPushClient(Communicator): # type: ignore[misc] sorted_objects.update(payload["after"]["data"]["sub_labels"]) - camera: str = payload["after"]["camera"] title = f"{', '.join(sorted_objects).replace('_', ' ').title()}{' was' if state == 'end' else ''} detected in {', '.join(payload['after']['data']['zones']).replace('_', ' ').title()}" message = f"Detected on {camera.replace('_', ' ').title()}" image = f"{payload['after']['thumb_path'].replace('/media/frigate', '')}" diff --git a/frigate/config/camera/notification.py b/frigate/config/camera/notification.py index 79355b8ae..b0d7cebf9 100644 --- a/frigate/config/camera/notification.py +++ b/frigate/config/camera/notification.py @@ -10,6 +10,9 @@ __all__ = ["NotificationConfig"] class NotificationConfig(FrigateBaseModel): enabled: bool = Field(default=False, title="Enable notifications") email: Optional[str] = Field(default=None, title="Email required for push.") + cooldown: Optional[int] = Field( + default=0, ge=0, title="Cooldown period for notifications (time in seconds)." + ) enabled_in_config: Optional[bool] = Field( default=None, title="Keep track of original state of notifications." ) diff --git a/web/components.json b/web/components.json index 053bbcf62..3f112537b 100644 --- a/web/components.json +++ b/web/components.json @@ -11,6 +11,9 @@ }, "aliases": { "components": "@/components", - "utils": "@/lib/utils" + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" } -} \ No newline at end of file +} diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index 07909a311..969e647a0 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -11,6 +11,9 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { @@ -24,12 +27,19 @@ import { VolumeSlider } from "@/components/ui/slider"; import { CameraStreamingDialog } from "../settings/CameraStreamingDialog"; import { AllGroupsStreamingSettings, + FrigateConfig, GroupStreamingSettings, } from "@/types/frigateConfig"; import { useStreamingSettings } from "@/context/streaming-settings-provider"; -import { IoIosWarning } from "react-icons/io"; +import { + IoIosNotifications, + IoIosNotificationsOff, + IoIosWarning, +} from "react-icons/io"; import { cn } from "@/lib/utils"; import { useNavigate } from "react-router-dom"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { useNotifications, useNotificationSuspend } from "@/api/ws"; type LiveContextMenuProps = { className?: string; @@ -48,6 +58,7 @@ type LiveContextMenuProps = { statsState: boolean; toggleStats: () => void; resetPreferredLiveMode: () => void; + config?: FrigateConfig; children?: ReactNode; }; export default function LiveContextMenu({ @@ -67,6 +78,7 @@ export default function LiveContextMenu({ statsState, toggleStats, resetPreferredLiveMode, + config, children, }: LiveContextMenuProps) { const [showSettings, setShowSettings] = useState(false); @@ -185,6 +197,44 @@ export default function LiveContextMenu({ const navigate = useNavigate(); + // notifications + + const notificationsEnabledInConfig = + config?.cameras[camera].notifications.enabled_in_config; + + const { payload: notificationState, send: sendNotification } = + useNotifications(camera); + const { payload: notificationSuspendUntil, send: sendNotificationSuspend } = + useNotificationSuspend(camera); + const [isSuspended, setIsSuspended] = useState(false); + + useEffect(() => { + if (notificationSuspendUntil) { + setIsSuspended( + notificationSuspendUntil !== "0" || notificationState === "OFF", + ); + } + }, [notificationSuspendUntil, notificationState]); + + const handleSuspend = (duration: string) => { + if (duration === "off") { + sendNotification("OFF"); + } else { + sendNotificationSuspend(Number.parseInt(duration)); + } + }; + + const formatSuspendedUntil = (timestamp: string) => { + if (timestamp === "0") return "Frigate restarts."; + + return formatUnixTimestampToDateTime(Number.parseInt(timestamp), { + time_style: "medium", + date_style: "medium", + timezone: config?.ui.timezone, + strftime_fmt: `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`, + }); + }; + return (
@@ -288,6 +338,115 @@ export default function LiveContextMenu({ )} + {notificationsEnabledInConfig && ( + <> + + + +
+ Notifications +
+
+ +
+
+ {notificationState === "ON" ? ( + <> + {isSuspended ? ( + <> + + Suspended + + ) : ( + <> + + Enabled + + )} + + ) : ( + <> + + Disabled + + )} +
+ {isSuspended && ( + + Until {formatSuspendedUntil(notificationSuspendUntil)} + + )} +
+ + {isSuspended ? ( + <> + + { + sendNotification("ON"); + sendNotificationSuspend(0); + }} + > +
+ {notificationState === "ON" ? ( + Unsuspend + ) : ( + Enable + )} +
+
+ + ) : ( + notificationState === "ON" && ( + <> + +
+

+ Suspend for: +

+
+ handleSuspend("5")}> + 5 minutes + + handleSuspend("10")} + > + 10 minutes + + handleSuspend("30")} + > + 30 minutes + + handleSuspend("60")} + > + 1 hour + + handleSuspend("840")} + > + 12 hours + + handleSuspend("1440")} + > + 24 hours + + handleSuspend("off")} + > + Until restart + +
+
+ + ) + )} +
+
+ + )}
diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx new file mode 100644 index 000000000..41fa7e056 --- /dev/null +++ b/web/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index fbf31a00a..6eeb5bcc3 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -61,19 +61,6 @@ export default function Settings() { const [searchParams] = useSearchParams(); - // available settings views - - const settingsViews = useMemo(() => { - const views = [...allSettingsViews]; - - if (!("Notification" in window) || !window.isSecureContext) { - const index = views.indexOf("notifications"); - views.splice(index, 1); - } - - return views; - }, []); - // TODO: confirm leave page const [unsavedChanges, setUnsavedChanges] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); @@ -160,7 +147,7 @@ export default function Settings() { } }} > - {Object.values(settingsViews).map((item) => ( + {Object.values(allSettingsViews).map((item) => ( resetPreferredLiveMode(camera.name) } + config={config} > void; unmuteAll: () => void; resetPreferredLiveMode: () => void; + config?: FrigateConfig; }; const GridLiveContextMenu = React.forwardRef< @@ -819,6 +821,7 @@ const GridLiveContextMenu = React.forwardRef< muteAll, unmuteAll, resetPreferredLiveMode, + config, ...props }, ref, @@ -849,6 +852,7 @@ const GridLiveContextMenu = React.forwardRef< muteAll={muteAll} unmuteAll={unmuteAll} resetPreferredLiveMode={resetPreferredLiveMode} + config={config} > {children} diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 89a2aeef2..45d0d5302 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -507,6 +507,7 @@ export default function LiveDashboardView({ resetPreferredLiveMode={() => resetPreferredLiveMode(camera.name) } + config={config} > (); useEffect(() => { + if (!("Notification" in window) || !window.isSecureContext) { + return; + } navigator.serviceWorker .getRegistration(NOTIFICATION_SERVICE_WORKER) .then((worker) => { @@ -279,6 +283,60 @@ export default function NotificationView({ saveToConfig(values as NotificationSettingsValueType); } + if (!("Notification" in window) || !window.isSecureContext) { + return ( +
+
+
+ + Notification Settings + +
+
+

+ Frigate can natively send push notifications to your device + when it is running in the browser or installed as a PWA. +

+
+ + Read the Documentation{" "} + + +
+
+
+ + + Notifications Unavailable + + + Web push notifications require a secure context ( + https://...). This is a browser limitation. Access + Frigate securely to use notifications. +
+ + Read the Documentation{" "} + + +
+
+
+
+
+
+ ); + } + return ( <>