Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement support for notifications #12523

Merged
merged 31 commits into from
Jul 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
daa3d5b
Setup basic notification page
NickM-27 Jul 10, 2024
999d676
Add basic notification implementation
NickM-27 Jul 10, 2024
0fd1ad0
Register for push notifications
NickM-27 Jul 10, 2024
c918382
Implement dispatching
NickM-27 Jul 10, 2024
4fd9a38
Add fields
NickM-27 Jul 19, 2024
41a0518
Handle image and link
NickM-27 Jul 19, 2024
b4633db
Add notification config
NickM-27 Jul 19, 2024
fbc35a3
Add field for users notification tokens
NickM-27 Jul 19, 2024
d40f177
Implement saving of notification tokens
NickM-27 Jul 19, 2024
afe2032
Implement VAPID key generation
NickM-27 Jul 20, 2024
5398a66
Implement public key encoding
NickM-27 Jul 20, 2024
f946690
Implement webpush from server
NickM-27 Jul 20, 2024
6fbab84
Implement push notification handling
NickM-27 Jul 20, 2024
242c254
Make notifications config only
NickM-27 Jul 20, 2024
104d56a
Add maskable icon
NickM-27 Jul 20, 2024
fa8a0cc
Use zod form to control notification settings in the UI
NickM-27 Jul 20, 2024
367555b
Use js
NickM-27 Jul 20, 2024
c92c825
Always open notification
NickM-27 Jul 20, 2024
f6ba1ab
Support multiple endpoints
NickM-27 Jul 20, 2024
9824cb8
Handle cleaning up expired notification registrations
NickM-27 Jul 21, 2024
9d81046
Correctly unsubscribe notifications
NickM-27 Jul 21, 2024
4808a7d
Change ttl dynamically
NickM-27 Jul 22, 2024
2cebde6
Add note about notification latency and features
NickM-27 Jul 22, 2024
fd0a9b3
Cleanup docs
NickM-27 Jul 22, 2024
710ab1e
Fix firefox pushes
NickM-27 Jul 22, 2024
037718f
Add links to docs and improve formatting
NickM-27 Jul 22, 2024
d0e46b3
Improve wording
NickM-27 Jul 22, 2024
258983d
Fix docstring
NickM-27 Jul 22, 2024
7e55af7
Handle case where native auth is not enabled
NickM-27 Jul 22, 2024
61f4bcd
Merge branch 'notifications' of github.com:blakeblackshear/frigate in…
NickM-27 Jul 22, 2024
273e7c0
Show errors in UI
NickM-27 Jul 22, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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