mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-01 16:42:18 +03:00
Improve Notifications (#16453)
* backend * frontend * add notification config at camera level * camera level notifications in dispatcher * initial onconnect * frontend * backend for suspended notifications * frontend * use base communicator * initialize all cameras in suspended array and use 0 for unsuspended * remove switch and use select for suspending in frontend * use timestamp instead of datetime * frontend tweaks * mqtt docs * fix button width * use grid for layout * use thread and queue for processing notifications with 10s timeout * clean up * move async code to main class * tweaks * docs * remove warning message
This commit is contained in:
+161
-50
@@ -4,13 +4,17 @@ import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any, Callable
|
||||
|
||||
from py_vapid import Vapid01
|
||||
from pywebpush import WebPusher
|
||||
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
from frigate.comms.config_updater import ConfigSubscriber
|
||||
from frigate.comms.dispatcher import Communicator
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import CONFIG_DIR
|
||||
from frigate.models import User
|
||||
@@ -18,15 +22,36 @@ from frigate.models import User
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PushNotification:
|
||||
user: str
|
||||
payload: dict[str, Any]
|
||||
title: str
|
||||
message: str
|
||||
direct_url: str = ""
|
||||
image: str = ""
|
||||
notification_type: str = "alert"
|
||||
ttl: int = 0
|
||||
|
||||
|
||||
class WebPushClient(Communicator): # type: ignore[misc]
|
||||
"""Frigate wrapper for webpush client."""
|
||||
|
||||
def __init__(self, config: FrigateConfig) -> None:
|
||||
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
|
||||
self.config = config
|
||||
self.claim_headers: dict[str, dict[str, str]] = {}
|
||||
self.refresh: int = 0
|
||||
self.web_pushers: dict[str, list[WebPusher]] = {}
|
||||
self.expired_subs: dict[str, list[str]] = {}
|
||||
self.suspended_cameras: dict[str, int] = {
|
||||
c.name: 0 for c in self.config.cameras.values()
|
||||
}
|
||||
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
|
||||
self.notification_thread = threading.Thread(
|
||||
target=self._process_notifications, daemon=True
|
||||
)
|
||||
self.notification_thread.start()
|
||||
self.stop_event = stop_event
|
||||
|
||||
if not self.config.notifications.email:
|
||||
logger.warning("Email must be provided for push notifications to be sent.")
|
||||
@@ -103,30 +128,144 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
||||
|
||||
self.expired_subs = {}
|
||||
|
||||
def suspend_notifications(self, camera: str, minutes: int) -> None:
|
||||
"""Suspend notifications for a specific camera."""
|
||||
suspend_until = int(
|
||||
(datetime.datetime.now() + datetime.timedelta(minutes=minutes)).timestamp()
|
||||
)
|
||||
self.suspended_cameras[camera] = suspend_until
|
||||
logger.info(
|
||||
f"Notifications for {camera} suspended until {datetime.datetime.fromtimestamp(suspend_until).strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
)
|
||||
|
||||
def unsuspend_notifications(self, camera: str) -> None:
|
||||
"""Unsuspend notifications for a specific camera."""
|
||||
self.suspended_cameras[camera] = 0
|
||||
logger.info(f"Notifications for {camera} unsuspended")
|
||||
|
||||
def is_camera_suspended(self, camera: str) -> bool:
|
||||
return datetime.datetime.now().timestamp() <= self.suspended_cameras[camera]
|
||||
|
||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||
"""Wrapper for publishing when client is in valid state."""
|
||||
# check for updated notification config
|
||||
_, updated_notification_config = self.config_subscriber.check_for_update()
|
||||
|
||||
if updated_notification_config:
|
||||
self.config.notifications = updated_notification_config
|
||||
for key, value in updated_notification_config.items():
|
||||
if key == "_global_notifications":
|
||||
self.config.notifications = value
|
||||
|
||||
if not self.config.notifications.enabled:
|
||||
return
|
||||
elif key in self.config.cameras:
|
||||
self.config.cameras[key].notifications = value
|
||||
|
||||
if topic == "reviews":
|
||||
self.send_alert(json.loads(payload))
|
||||
decoded = json.loads(payload)
|
||||
camera = decoded["before"]["camera"]
|
||||
if not self.config.cameras[camera].notifications.enabled:
|
||||
return
|
||||
if self.is_camera_suspended(camera):
|
||||
logger.debug(f"Notifications for {camera} are currently suspended.")
|
||||
return
|
||||
self.send_alert(decoded)
|
||||
elif topic == "notification_test":
|
||||
if not self.config.notifications.enabled:
|
||||
return
|
||||
self.send_notification_test()
|
||||
|
||||
def send_alert(self, payload: dict[str, any]) -> None:
|
||||
def send_push_notification(
|
||||
self,
|
||||
user: str,
|
||||
payload: dict[str, Any],
|
||||
title: str,
|
||||
message: str,
|
||||
direct_url: str = "",
|
||||
image: str = "",
|
||||
notification_type: str = "alert",
|
||||
ttl: int = 0,
|
||||
) -> None:
|
||||
notification = PushNotification(
|
||||
user=user,
|
||||
payload=payload,
|
||||
title=title,
|
||||
message=message,
|
||||
direct_url=direct_url,
|
||||
image=image,
|
||||
notification_type=notification_type,
|
||||
ttl=ttl,
|
||||
)
|
||||
self.notification_queue.put(notification)
|
||||
|
||||
def _process_notifications(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
notification = self.notification_queue.get(timeout=1.0)
|
||||
self.check_registrations()
|
||||
|
||||
for pusher in self.web_pushers[notification.user]:
|
||||
endpoint = pusher.subscription_info["endpoint"]
|
||||
headers = self.claim_headers[
|
||||
endpoint[: endpoint.index("/", 10)]
|
||||
].copy()
|
||||
headers["urgency"] = "high"
|
||||
|
||||
resp = pusher.send(
|
||||
headers=headers,
|
||||
ttl=notification.ttl,
|
||||
data=json.dumps(
|
||||
{
|
||||
"title": notification.title,
|
||||
"message": notification.message,
|
||||
"direct_url": notification.direct_url,
|
||||
"image": notification.image,
|
||||
"id": notification.payload.get("after", {}).get(
|
||||
"id", ""
|
||||
),
|
||||
"type": notification.notification_type,
|
||||
}
|
||||
),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if resp.status_code in (404, 410):
|
||||
self.expired_subs.setdefault(notification.user, []).append(
|
||||
endpoint
|
||||
)
|
||||
elif resp.status_code != 201:
|
||||
logger.warning(
|
||||
f"Failed to send notification to {notification.user} :: {resp.status_code}"
|
||||
)
|
||||
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing notification: {str(e)}")
|
||||
|
||||
def send_notification_test(self) -> None:
|
||||
if not self.config.notifications.email:
|
||||
return
|
||||
|
||||
self.check_registrations()
|
||||
|
||||
# Only notify for alerts
|
||||
if payload["after"]["severity"] != "alert":
|
||||
for user in self.web_pushers:
|
||||
self.send_push_notification(
|
||||
user=user,
|
||||
payload={},
|
||||
title="Test Notification",
|
||||
message="This is a test notification from Frigate.",
|
||||
direct_url="/",
|
||||
notification_type="test",
|
||||
)
|
||||
|
||||
def send_alert(self, payload: dict[str, Any]) -> None:
|
||||
if (
|
||||
not self.config.notifications.email
|
||||
or payload["after"]["severity"] != "alert"
|
||||
):
|
||||
return
|
||||
|
||||
self.check_registrations()
|
||||
|
||||
state = payload["type"]
|
||||
|
||||
# Don't notify if message is an update and important fields don't have an update
|
||||
@@ -155,49 +294,21 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
||||
|
||||
# if event is ongoing open to live view otherwise open to recordings view
|
||||
direct_url = f"/review?id={reviewId}" if state == "end" else f"/#{camera}"
|
||||
ttl = 3600 if state == "end" else 0
|
||||
|
||||
for user, pushers in self.web_pushers.items():
|
||||
for pusher in pushers:
|
||||
endpoint = pusher.subscription_info["endpoint"]
|
||||
|
||||
# set headers for notification behavior
|
||||
headers = self.claim_headers[
|
||||
endpoint[0 : endpoint.index("/", 10)]
|
||||
].copy()
|
||||
headers["urgency"] = "high"
|
||||
ttl = 3600 if state == "end" else 0
|
||||
|
||||
# send message
|
||||
resp = pusher.send(
|
||||
headers=headers,
|
||||
ttl=ttl,
|
||||
data=json.dumps(
|
||||
{
|
||||
"title": title,
|
||||
"message": message,
|
||||
"direct_url": direct_url,
|
||||
"image": image,
|
||||
"id": reviewId,
|
||||
"type": "alert",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
if resp.status_code == 201:
|
||||
pass
|
||||
elif resp.status_code == 404 or resp.status_code == 410:
|
||||
# subscription is not found or has been unsubscribed
|
||||
if not self.expired_subs.get(user):
|
||||
self.expired_subs[user] = []
|
||||
|
||||
self.expired_subs[user].append(pusher.subscription_info["endpoint"])
|
||||
# the subscription no longer exists and should be removed
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to send notification to {user} :: {resp.headers}"
|
||||
)
|
||||
for user in self.web_pushers:
|
||||
self.send_push_notification(
|
||||
user=user,
|
||||
payload=payload,
|
||||
title=title,
|
||||
message=message,
|
||||
direct_url=direct_url,
|
||||
image=image,
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
self.cleanup_registrations()
|
||||
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
logger.info("Closing notification queue")
|
||||
self.notification_thread.join()
|
||||
|
||||
Reference in New Issue
Block a user