2024-07-22 22:39:15 +02:00
|
|
|
"""Handle sending notifications for Frigate via Firebase."""
|
|
|
|
|
|
|
|
import datetime
|
|
|
|
import json
|
|
|
|
import logging
|
|
|
|
import os
|
2025-02-11 03:47:15 +01:00
|
|
|
import queue
|
|
|
|
import threading
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from multiprocessing.synchronize import Event as MpEvent
|
2024-07-22 22:39:15 +02:00
|
|
|
from typing import Any, Callable
|
|
|
|
|
|
|
|
from py_vapid import Vapid01
|
|
|
|
from pywebpush import WebPusher
|
|
|
|
|
2025-02-11 03:47:15 +01:00
|
|
|
from frigate.comms.base_communicator import Communicator
|
2024-09-10 19:24:44 +02:00
|
|
|
from frigate.comms.config_updater import ConfigSubscriber
|
2024-07-22 22:39:15 +02:00
|
|
|
from frigate.config import FrigateConfig
|
|
|
|
from frigate.const import CONFIG_DIR
|
|
|
|
from frigate.models import User
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
2025-02-11 03:47:15 +01:00
|
|
|
@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
|
|
|
|
|
|
|
|
|
2024-07-22 22:39:15 +02:00
|
|
|
class WebPushClient(Communicator): # type: ignore[misc]
|
|
|
|
"""Frigate wrapper for webpush client."""
|
|
|
|
|
2025-02-11 03:47:15 +01:00
|
|
|
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
|
2024-07-22 22:39:15 +02:00
|
|
|
self.config = config
|
2025-02-11 04:22:33 +01:00
|
|
|
self.stop_event = stop_event
|
2024-07-22 22:39:15 +02:00
|
|
|
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]] = {}
|
2025-02-11 03:47:15 +01:00
|
|
|
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()
|
2024-07-22 22:39:15 +02:00
|
|
|
|
|
|
|
if not self.config.notifications.email:
|
|
|
|
logger.warning("Email must be provided for push notifications to be sent.")
|
|
|
|
|
|
|
|
# Pull keys from PEM or generate if they do not exist
|
|
|
|
self.vapid = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem"))
|
|
|
|
|
|
|
|
users: list[User] = (
|
|
|
|
User.select(User.username, User.notification_tokens).dicts().iterator()
|
|
|
|
)
|
|
|
|
for user in users:
|
|
|
|
self.web_pushers[user["username"]] = []
|
|
|
|
for sub in user["notification_tokens"]:
|
|
|
|
self.web_pushers[user["username"]].append(WebPusher(sub))
|
|
|
|
|
2024-09-10 19:24:44 +02:00
|
|
|
# notification config updater
|
|
|
|
self.config_subscriber = ConfigSubscriber("config/notifications")
|
|
|
|
|
2024-07-22 22:39:15 +02:00
|
|
|
def subscribe(self, receiver: Callable) -> None:
|
|
|
|
"""Wrapper for allowing dispatcher to subscribe."""
|
|
|
|
pass
|
|
|
|
|
|
|
|
def check_registrations(self) -> None:
|
|
|
|
# check for valid claim or create new one
|
|
|
|
now = datetime.datetime.now().timestamp()
|
|
|
|
if len(self.claim_headers) == 0 or self.refresh < now:
|
|
|
|
self.refresh = int(
|
|
|
|
(datetime.datetime.now() + datetime.timedelta(hours=1)).timestamp()
|
|
|
|
)
|
|
|
|
endpoints: set[str] = set()
|
|
|
|
|
|
|
|
# get a unique set of push endpoints
|
|
|
|
for pushers in self.web_pushers.values():
|
|
|
|
for push in pushers:
|
|
|
|
endpoint: str = push.subscription_info["endpoint"]
|
|
|
|
endpoints.add(endpoint[0 : endpoint.index("/", 10)])
|
|
|
|
|
|
|
|
# create new claim
|
|
|
|
for endpoint in endpoints:
|
|
|
|
claim = {
|
|
|
|
"sub": f"mailto:{self.config.notifications.email}",
|
|
|
|
"aud": endpoint,
|
|
|
|
"exp": self.refresh,
|
|
|
|
}
|
|
|
|
self.claim_headers[endpoint] = self.vapid.sign(claim)
|
|
|
|
|
|
|
|
def cleanup_registrations(self) -> None:
|
|
|
|
# delete any expired subs
|
|
|
|
if len(self.expired_subs) > 0:
|
|
|
|
for user, expired in self.expired_subs.items():
|
|
|
|
user_subs = []
|
|
|
|
|
|
|
|
# get all subscriptions, removing ones that are expired
|
|
|
|
stored_user: User = User.get_by_id(user)
|
|
|
|
for token in stored_user.notification_tokens:
|
|
|
|
if token["endpoint"] in expired:
|
|
|
|
continue
|
|
|
|
|
|
|
|
user_subs.append(token)
|
|
|
|
|
|
|
|
# overwrite the database and reset web pushers
|
|
|
|
User.update(notification_tokens=user_subs).where(
|
|
|
|
User.username == user
|
|
|
|
).execute()
|
|
|
|
|
|
|
|
self.web_pushers[user] = []
|
|
|
|
|
|
|
|
for sub in user_subs:
|
|
|
|
self.web_pushers[user].append(WebPusher(sub))
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
f"Cleaned up {len(expired)} notification subscriptions for {user}"
|
|
|
|
)
|
|
|
|
|
|
|
|
self.expired_subs = {}
|
|
|
|
|
2025-02-11 03:47:15 +01:00
|
|
|
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]
|
|
|
|
|
2024-07-22 22:39:15 +02:00
|
|
|
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
|
|
|
"""Wrapper for publishing when client is in valid state."""
|
2024-09-10 19:24:44 +02:00
|
|
|
# check for updated notification config
|
2024-09-17 17:41:46 +02:00
|
|
|
_, updated_notification_config = self.config_subscriber.check_for_update()
|
2024-09-10 19:24:44 +02:00
|
|
|
|
2024-09-17 17:41:46 +02:00
|
|
|
if updated_notification_config:
|
2025-02-11 03:47:15 +01:00
|
|
|
for key, value in updated_notification_config.items():
|
|
|
|
if key == "_global_notifications":
|
|
|
|
self.config.notifications = value
|
2024-09-10 19:24:44 +02:00
|
|
|
|
2025-02-11 03:47:15 +01:00
|
|
|
elif key in self.config.cameras:
|
|
|
|
self.config.cameras[key].notifications = value
|
2024-09-10 19:24:44 +02:00
|
|
|
|
2024-07-22 22:39:15 +02:00
|
|
|
if topic == "reviews":
|
2025-02-11 03:47:15 +01:00
|
|
|
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_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,
|
|
|
|
)
|
2024-07-22 22:39:15 +02:00
|
|
|
|
2025-02-11 03:47:15 +01:00
|
|
|
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:
|
2024-07-22 22:39:15 +02:00
|
|
|
if not self.config.notifications.email:
|
|
|
|
return
|
|
|
|
|
|
|
|
self.check_registrations()
|
|
|
|
|
2025-02-11 03:47:15 +01:00
|
|
|
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"
|
|
|
|
):
|
2024-07-22 22:39:15 +02:00
|
|
|
return
|
|
|
|
|
2025-02-11 03:47:15 +01:00
|
|
|
self.check_registrations()
|
|
|
|
|
2024-07-22 22:39:15 +02:00
|
|
|
state = payload["type"]
|
|
|
|
|
|
|
|
# Don't notify if message is an update and important fields don't have an update
|
|
|
|
if (
|
|
|
|
state == "update"
|
|
|
|
and len(payload["before"]["data"]["objects"])
|
|
|
|
== len(payload["after"]["data"]["objects"])
|
|
|
|
and len(payload["before"]["data"]["zones"])
|
|
|
|
== len(payload["after"]["data"]["zones"])
|
|
|
|
):
|
|
|
|
return
|
|
|
|
|
|
|
|
reviewId = payload["after"]["id"]
|
|
|
|
sorted_objects: set[str] = set()
|
|
|
|
|
|
|
|
for obj in payload["after"]["data"]["objects"]:
|
|
|
|
if "-verified" not in obj:
|
|
|
|
sorted_objects.add(obj)
|
|
|
|
|
|
|
|
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()}"
|
2025-01-11 15:04:11 +01:00
|
|
|
image = f"{payload['after']['thumb_path'].replace('/media/frigate', '')}"
|
2024-07-22 22:39:15 +02:00
|
|
|
|
|
|
|
# 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}"
|
2025-02-11 03:47:15 +01:00
|
|
|
ttl = 3600 if state == "end" else 0
|
|
|
|
|
|
|
|
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,
|
|
|
|
)
|
2024-07-22 22:39:15 +02:00
|
|
|
|
|
|
|
self.cleanup_registrations()
|
|
|
|
|
|
|
|
def stop(self) -> None:
|
2025-02-11 03:47:15 +01:00
|
|
|
logger.info("Closing notification queue")
|
|
|
|
self.notification_thread.join()
|