-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement support for notifications (#12523)
* 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
1 parent
8c40bd2
commit 1ae16d4
Showing
18 changed files
with
795 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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: | ||
|
@@ -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. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.