Skip to content

Commit

Permalink
Implement support for notifications (blakeblackshear#12523)
Browse files Browse the repository at this point in the history
* Setup basic notification page

* Add basic notification implementation

* Register for push notifications

* Implement dispatching

* Add fields

* Handle image and link

* Add notification config

* Add field for users notification tokens

* Implement saving of notification tokens

* Implement VAPID key generation

* Implement public key encoding

* Implement webpush from server

* Implement push notification handling

* Make notifications config only

* Add maskable icon

* Use zod form to control notification settings in the UI

* Use js

* Always open notification

* Support multiple endpoints

* Handle cleaning up expired notification registrations

* Correctly unsubscribe notifications

* Change ttl dynamically

* Add note about notification latency and features

* Cleanup docs

* Fix firefox pushes

* Add links to docs and improve formatting

* Improve wording

* Fix docstring

Co-authored-by: Blake Blackshear <[email protected]>

* Handle case where native auth is not enabled

* Show errors in UI

---------

Co-authored-by: Blake Blackshear <[email protected]>
  • Loading branch information
2 people authored and iursevla committed Sep 1, 2024
1 parent ab64ab6 commit 24b0850
Show file tree
Hide file tree
Showing 18 changed files with 795 additions and 14 deletions.
5 changes: 4 additions & 1 deletion docker/main/requirements-wheels.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,7 @@ chromadb == 0.5.0
# Generative AI
google-generativeai == 0.6.*
ollama == 0.2.*
openai == 1.30.*
openai == 1.30.*
# push notifications
py-vapid == 1.9.*
pywebpush == 2.0.*
42 changes: 42 additions & 0 deletions docs/docs/configuration/notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
id: notifications
title: Notifications
---

# Notifications

Frigate offers native notifications using the [WebPush Protocol](https://web.dev/articles/push-notifications-web-push-protocol) which uses the [VAPID spec](https://tools.ietf.org/html/draft-thomson-webpush-vapid) to deliver notifications to web apps using encryption.

## Setting up Notifications

In order to use notifications the following requirements must be met:

- Frigate must be accessed via a secure https connection
- 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

### Configuration

To configure notifications, go to the Frigate WebUI -> Settings -> Notifications and enable, then fill out the fields and save.

### 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.

## Supported Notifications

Currently notifications are only supported for review alerts. More notifications will be supported in the future.

:::note

Currently, only Chrome supports images in notifications. Safari and Firefox will only show a title and message in the notification.

:::

## Reduce Notification Latency

Different platforms handle notifications differently, some settings changes may be required to get optimal notification delivery.

### 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.
12 changes: 10 additions & 2 deletions docs/docs/configuration/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,14 @@ motion:
# Optional: Delay when updating camera motion through MQTT from ON -> OFF (default: shown below).
mqtt_off_delay: 30

# Optional: Notification Configuration
notifications:
# Optional: Enable notification service (default: shown below)
enabled: False
# Optional: Email for push service to reach out to
# NOTE: This is required to use notifications
email: "[email protected]"

# Optional: Record configuration
# NOTE: Can be overridden at the camera level
record:
Expand Down Expand Up @@ -642,8 +650,8 @@ cameras:
user: admin
# Optional: password for login.
password: admin
# Optional: Ignores time synchronization mismatches between the camera and the server during authentication.
# Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents.
# Optional: Ignores time synchronization mismatches between the camera and the server during authentication.
# Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents.
ignore_time_mismatch: False
# Optional: PTZ camera object autotracking. Keeps a moving object in
# the center of the frame by automatically moving the PTZ camera.
Expand Down
1 change: 1 addition & 0 deletions docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ module.exports = {
],
"Extra Configuration": [
"configuration/authentication",
"configuration/notifications",
"configuration/hardware_acceleration",
"configuration/ffmpeg_presets",
"configuration/tls",
Expand Down
2 changes: 2 additions & 0 deletions frigate/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from frigate.api.event import EventBp
from frigate.api.export import ExportBp
from frigate.api.media import MediaBp
from frigate.api.notification import NotificationBp
from frigate.api.preview import PreviewBp
from frigate.api.review import ReviewBp
from frigate.config import FrigateConfig
Expand Down Expand Up @@ -48,6 +49,7 @@
bp.register_blueprint(PreviewBp)
bp.register_blueprint(ReviewBp)
bp.register_blueprint(AuthBp)
bp.register_blueprint(NotificationBp)


def create_app(
Expand Down
65 changes: 65 additions & 0 deletions frigate/api/notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Notification apis."""

import logging
import os

from cryptography.hazmat.primitives import serialization
from flask import (
Blueprint,
current_app,
jsonify,
make_response,
request,
)
from peewee import DoesNotExist
from py_vapid import Vapid01, utils

from frigate.const import CONFIG_DIR
from frigate.models import User

logger = logging.getLogger(__name__)

NotificationBp = Blueprint("notifications", __name__)


@NotificationBp.route("/notifications/pubkey", methods=["GET"])
def get_vapid_pub_key():
if not current_app.frigate_config.notifications.enabled:
return make_response(
jsonify({"success": False, "message": "Notifications are not enabled."}),
400,
)

key = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem"))
raw_pub = key.public_key.public_bytes(
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
)
return jsonify(utils.b64urlencode(raw_pub)), 200


@NotificationBp.route("/notifications/register", methods=["POST"])
def register_notifications():
if current_app.frigate_config.auth.enabled:
username = request.headers.get("remote-user", type=str) or "admin"
else:
username = "admin"

json: dict[str, any] = request.get_json(silent=True) or {}
sub = json.get("sub")

if not sub:
return jsonify(
{"success": False, "message": "Subscription must be provided."}
), 400

try:
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
User.username == username
).execute()
return make_response(
jsonify({"success": True, "message": "Successfully saved token."}), 200
)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Could not find user."}), 404
)
4 changes: 4 additions & 0 deletions frigate/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from frigate.comms.dispatcher import Communicator, Dispatcher
from frigate.comms.inter_process import InterProcessCommunicator
from frigate.comms.mqtt import MqttClient
from frigate.comms.webpush import WebPushClient
from frigate.comms.ws import WebSocketClient
from frigate.comms.zmq_proxy import ZmqProxy
from frigate.config import FrigateConfig
Expand Down Expand Up @@ -401,6 +402,9 @@ def init_dispatcher(self) -> None:
if self.config.mqtt.enabled:
comms.append(MqttClient(self.config))

if self.config.notifications.enabled:
comms.append(WebPushClient(self.config))

comms.append(WebSocketClient(self.config))
comms.append(self.inter_process_communicator)

Expand Down
189 changes: 189 additions & 0 deletions frigate/comms/webpush.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""Handle sending notifications for Frigate via Firebase."""

import datetime
import json
import logging
import os
from typing import Any, Callable

from py_vapid import Vapid01
from pywebpush import WebPusher

from frigate.comms.dispatcher import Communicator
from frigate.config import FrigateConfig
from frigate.const import CONFIG_DIR
from frigate.models import User

logger = logging.getLogger(__name__)


class WebPushClient(Communicator): # type: ignore[misc]
"""Frigate wrapper for webpush client."""

def __init__(self, config: FrigateConfig) -> 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]] = {}

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))

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 = {}

def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state."""
if topic == "reviews":
self.send_alert(json.loads(payload))

def send_alert(self, payload: dict[str, any]) -> None:
if not self.config.notifications.email:
return

self.check_registrations()

# Only notify for alerts
if payload["after"]["severity"] != "alert":
return

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()}"
image = f'{payload["after"]["thumb_path"].replace("/media/frigate", "")}'

# 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}"

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,
}
),
)

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}"
)

self.cleanup_registrations()

def stop(self) -> None:
pass
Loading

0 comments on commit 24b0850

Please sign in to comment.