Files
frigate/frigate/comms/webpush.py
T

483 lines
17 KiB
Python
Raw Normal View History

"""Handle sending notifications for Frigate via Firebase."""
import datetime
import json
import logging
import os
2025-02-10 20:47:15 -06:00
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
2025-05-17 17:11:19 -05:00
from titlecase import titlecase
2025-02-10 20:47:15 -06:00
from frigate.comms.base_communicator import Communicator
from frigate.comms.config_updater import ConfigSubscriber
from frigate.config import FrigateConfig
2025-05-22 12:16:51 -06:00
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
from frigate.const import BASE_DIR, CONFIG_DIR
from frigate.models import User
logger = logging.getLogger(__name__)
2025-02-10 20:47:15 -06: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
2025-08-08 06:08:37 -06:00
class WebPushClient(Communicator):
"""Frigate wrapper for webpush client."""
2025-02-10 20:47:15 -06:00
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
self.config = config
2025-02-10 20:22:33 -07:00
self.stop_event = stop_event
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-10 20:47:15 -06:00
self.suspended_cameras: dict[str, int] = {
2025-08-08 06:08:37 -06:00
c.name: 0 # type: ignore[misc]
for c in self.config.cameras.values()
2025-02-10 20:47:15 -06:00
}
2025-02-17 08:19:03 -06:00
self.last_camera_notification_time: dict[str, float] = {
2025-08-08 06:08:37 -06:00
c.name: 0 # type: ignore[misc]
for c in self.config.cameras.values()
2025-02-17 08:19:03 -06:00
}
self.last_notification_time: float = 0
2025-02-10 20:47:15 -06:00
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
self.notification_thread = threading.Thread(
target=self._process_notifications, daemon=True
)
self.notification_thread.start()
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"))
2025-08-08 11:25:39 -06:00
users: list[dict[str, Any]] = (
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))
# notification config updater
2025-05-22 12:16:51 -06:00
self.global_config_subscriber = ConfigSubscriber(
"config/notifications", exact=True
)
self.config_subscriber = CameraConfigUpdateSubscriber(
2025-06-11 11:25:30 -06:00
self.config, self.config.cameras, [CameraConfigUpdateEnum.notifications]
2025-05-22 12:16:51 -06: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-10 20:47:15 -06: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]
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
2025-05-22 12:16:51 -06:00
_, updated_notification_config = (
self.global_config_subscriber.check_for_update()
)
if updated_notification_config:
2025-05-22 12:16:51 -06:00
self.config.notifications = updated_notification_config
2025-06-11 11:25:30 -06:00
updates = self.config_subscriber.check_for_updates()
if "add" in updates:
for camera in updates["add"]:
self.suspended_cameras[camera] = 0
self.last_camera_notification_time[camera] = 0
if topic == "reviews":
2025-02-10 20:47:15 -06: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)
2025-07-07 09:03:57 -05:00
if topic == "triggers":
decoded = json.loads(payload)
camera = decoded["camera"]
name = decoded["name"]
# ensure notifications are enabled and the specific trigger has
# notification action enabled
if (
not self.config.cameras[camera].notifications.enabled
or name not in self.config.cameras[camera].semantic_search.triggers
or "notification"
not in self.config.cameras[camera]
.semantic_search.triggers[name]
.actions
):
return
if self.is_camera_suspended(camera):
logger.debug(f"Notifications for {camera} are currently suspended.")
return
self.send_trigger(decoded)
2025-02-10 20:47:15 -06:00
elif topic == "notification_test":
2025-05-16 17:37:25 -05:00
if not self.config.notifications.enabled and not any(
cam.notifications.enabled for cam in self.config.cameras.values()
):
logger.debug(
"No cameras have notifications enabled, test notification not sent"
)
2025-02-10 20:47:15 -06:00
return
self.send_notification_test()
2025-02-10 20:47:15 -06:00
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
)
2025-07-22 09:20:42 -05:00
logger.debug(
f"Notification endpoint expired for {notification.user}, received {resp.status_code}"
)
2025-02-10 20:47:15 -06:00
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)}")
2025-07-07 09:03:57 -05:00
def _within_cooldown(self, camera: str) -> bool:
now = datetime.datetime.now().timestamp()
if now - self.last_notification_time < self.config.notifications.cooldown:
logger.debug(
f"Skipping notification for {camera} - in global cooldown period"
)
return True
if (
now - 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 True
return False
2025-02-10 20:47:15 -06:00
def send_notification_test(self) -> None:
if not self.config.notifications.email:
return
self.check_registrations()
2025-07-22 09:20:42 -05:00
logger.debug("Sending test notification")
2025-02-10 20:47:15 -06: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"
):
return
2025-02-17 08:19:03 -06:00
camera: str = payload["after"]["camera"]
2025-08-27 01:15:01 +08:00
camera_name: str = getattr(
2025-08-26 15:29:52 -05:00
self.config.cameras[camera], "friendly_name", None
2025-08-27 01:15:01 +08:00
) or titlecase(camera.replace("_", " "))
2025-02-17 08:19:03 -06:00
current_time = datetime.datetime.now().timestamp()
2025-07-07 09:03:57 -05:00
if self._within_cooldown(camera):
2025-02-17 08:19:03 -06:00
return
2025-02-10 20:47:15 -06:00
self.check_registrations()
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"])
):
2025-05-09 08:36:44 -05:00
logger.debug(
f"Skipping notification for {camera} - message is an update and important fields don't have an update"
)
return
2025-02-17 08:19:03 -06:00
self.last_camera_notification_time[camera] = current_time
self.last_notification_time = current_time
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"])
image = f"{payload['after']['thumb_path'].replace(BASE_DIR, '')}"
2025-08-10 05:57:54 -06:00
ended = state == "end" or state == "genai"
if state == "genai" and payload["after"]["data"]["metadata"]:
2025-11-27 07:58:35 -06:00
base_title = payload["after"]["data"]["metadata"]["title"]
threat_level = payload["after"]["data"]["metadata"].get(
"potential_threat_level", 0
)
# Add prefix for threat levels 1 and 2
if threat_level == 1:
title = f"Needs Review: {base_title}"
elif threat_level == 2:
title = f"Security Concern: {base_title}"
else:
title = base_title
2025-12-26 07:45:03 -07:00
message = payload["after"]["data"]["metadata"]["shortSummary"]
2025-08-10 05:57:54 -06:00
else:
2025-12-07 08:57:46 -06:00
zone_names = payload["after"]["data"]["zones"]
formatted_zone_names = []
for zone_name in zone_names:
if zone_name in self.config.cameras[camera].zones:
formatted_zone_names.append(
self.config.cameras[camera]
.zones[zone_name]
.get_formatted_name(zone_name)
)
else:
formatted_zone_names.append(titlecase(zone_name.replace("_", " ")))
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {', '.join(formatted_zone_names)}"
2025-08-27 01:15:01 +08:00
message = f"Detected on {camera_name}"
2025-08-10 05:57:54 -06:00
if ended:
logger.debug(
f"Sending a notification with state {state} and message {message}"
)
# if event is ongoing open to live view otherwise open to recordings view
2025-08-10 05:57:54 -06:00
direct_url = f"/review?id={reviewId}" if ended else f"/#{camera}"
ttl = 3600 if ended else 0
2025-05-09 08:36:44 -05:00
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
2025-02-10 20:47:15 -06:00
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()
2025-07-07 09:03:57 -05:00
def send_trigger(self, payload: dict[str, Any]) -> None:
if not self.config.notifications.email:
return
camera: str = payload["camera"]
2025-08-27 01:15:01 +08:00
camera_name: str = getattr(
2025-08-26 15:29:52 -05:00
self.config.cameras[camera], "friendly_name", None
2025-08-27 01:15:01 +08:00
) or titlecase(camera.replace("_", " "))
2025-07-07 09:03:57 -05:00
current_time = datetime.datetime.now().timestamp()
if self._within_cooldown(camera):
return
self.check_registrations()
self.last_camera_notification_time[camera] = current_time
self.last_notification_time = current_time
trigger_type = payload["type"]
event_id = payload["event_id"]
name = payload["name"]
score = payload["score"]
2025-08-27 01:15:01 +08:00
title = f"{name.replace('_', ' ')} triggered on {camera_name}"
message = f"{titlecase(trigger_type)} trigger fired for {camera_name} with score {score:.2f}"
2025-07-07 09:03:57 -05:00
image = f"clips/triggers/{camera}/{event_id}.webp"
direct_url = f"/explore?event_id={event_id}"
ttl = 0
2025-08-27 01:15:01 +08:00
logger.debug(
f"Sending push notification for {camera_name}, trigger name {name}"
)
2025-07-07 09:03:57 -05:00
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:
2025-02-10 20:47:15 -06:00
logger.info("Closing notification queue")
self.notification_thread.join()