Implement saving of notification tokens

This commit is contained in:
Nicolas Mowen 2024-07-19 15:41:17 -06:00
parent fbc35a36b1
commit d40f177b77
16 changed files with 247 additions and 1020 deletions

View File

@ -37,5 +37,3 @@ chromadb == 0.5.0
google-generativeai == 0.6.*
ollama == 0.2.*
openai == 1.30.*
# Notifications
firebase_admin == 6.5.0

View File

@ -19,6 +19,7 @@ from frigate.api.auth import AuthBp, get_jwt_secret, limiter
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
@ -48,6 +49,7 @@ bp.register_blueprint(MediaBp)
bp.register_blueprint(PreviewBp)
bp.register_blueprint(ReviewBp)
bp.register_blueprint(AuthBp)
bp.register_blueprint(NotificationBp)
def create_app(

View File

@ -0,0 +1,34 @@
"""Notification apis."""
import logging
from flask import (
Blueprint,
jsonify,
request,
)
from peewee import DoesNotExist
from frigate.models import User
logger = logging.getLogger(__name__)
NotificationBp = Blueprint("notifications", __name__)
@NotificationBp.route("/notifications/register", methods=["POST"])
def register_notifications():
username = request.headers.get("remote-user", type=str) or "admin"
json: dict[str, any] = request.get_json(silent=True) or {}
token = json["token"]
if not token:
return jsonify({"success": False, "message": "Token must be provided."}), 400
try:
User.update(notification_tokens=User.notification_tokens.append(token)).where(
User.username == username
).execute()
return jsonify({"success": True, "message": "Successfully saved token."}), 200
except DoesNotExist:
return jsonify({"success": False, "message": "Could not find user."}), 404

View File

@ -23,9 +23,9 @@ from frigate.api.app import create_app
from frigate.api.auth import hash_password
from frigate.comms.config_updater import ConfigPublisher
from frigate.comms.dispatcher import Communicator, Dispatcher
from frigate.comms.firebase import FirebaseClient
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
@ -403,7 +403,7 @@ class FrigateApp:
comms.append(MqttClient(self.config))
if self.config.notifications.enabled:
comms.append(FirebaseClient(self.config, self.stop_event))
comms.append(WebPushClient(self.config))
comms.append(WebSocketClient(self.config))
comms.append(self.inter_process_communicator)

View File

@ -1,97 +0,0 @@
"""Handle sending notifications for Frigate via Firebase."""
import json
import logging
import threading
from multiprocessing.synchronize import Event as MpEvent
from typing import Any, Callable
import firebase_admin
from firebase_admin import credentials, messaging
from frigate.comms.dispatcher import Communicator
from frigate.config import FrigateConfig
logger = logging.getLogger(__name__)
class FirebaseClient(Communicator): # type: ignore[misc]
"""Frigate wrapper for firebase client."""
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
self.messenger = FirebaseMessenger(config, stop_event)
self.messenger.start()
def subscribe(self, receiver: Callable) -> None:
"""Wrapper for allowing dispatcher to subscribe."""
pass
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state."""
if topic == "reviews":
self.messenger.send_message(json.loads(payload))
def stop(self) -> None:
pass
class FirebaseMessenger(threading.Thread):
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
threading.Thread.__init__(self)
self.name = "firebase_messenger"
self.config = config
self.stop_event = stop_event
def send_message(self, payload: dict[str, any]) -> None:
# 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"])
message = messaging.MulticastMessage(
notification=messaging.Notification(
title=f"{', '.join(sorted_objects).replace('_', ' ').title()}{' was' if state == 'end' else ''} detected in {', '.join(payload['after']['data']['zones']).replace('_', ' ').title()}",
body=f"Detected on {payload['after']['camera'].replace('_', ' ').title()}",
),
webpush=messaging.WebpushConfig(
fcm_options=messaging.WebpushFCMOptions(
link=f"{self.config.notifications.base_url}/review?id={reviewId}"
)
),
data={"id": reviewId, "imageUrl": f'{self.config.notifications.base_url}{payload["after"]["thumb_path"].replace("/media/frigate", "")}'},
tokens=[
"cNNicZp6S92qn4kAVJnzd7:APA91bGv-MvDmNoZ2xqJTkPyCTmyv2WG0tfwIqWUuNtq3SXlpQJpdPCCjTEehOLDa0Yphv__KdxOQYEfaFvYfTW2qQevX-tSnRCVa_sJazQ_rfTervpo_zBVJD1T5GfYaY6kr41Wr_fP"
],
)
messaging.send_multicast(message)
def run(self) -> None:
try:
firebase_admin.get_app()
except ValueError:
cred = credentials.Certificate("/config/firebase-priv-key.json")
firebase_admin.initialize_app(credential=cred)
while self.stop_event.wait(0.1):
# TODO check for a delete invalid tokens
pass

70
frigate/comms/webpush.py Normal file
View File

@ -0,0 +1,70 @@
"""Handle sending notifications for Frigate via Firebase."""
import json
import logging
from typing import Any, Callable
from frigate.comms.dispatcher import Communicator
from frigate.config import FrigateConfig
from frigate.models import User
logger = logging.getLogger(__name__)
class WebPushClient(Communicator): # type: ignore[misc]
"""Frigate wrapper for firebase client."""
def __init__(self, config: FrigateConfig) -> None:
self.config = config
# TODO check for VAPID key
self.tokens = []
self.invalid_tokens = []
users: list[User] = User.select(User.notification_tokens).dicts().iterator()
for user in users:
self.tokens.extend(user["notification_tokens"])
def subscribe(self, receiver: Callable) -> None:
"""Wrapper for allowing dispatcher to subscribe."""
pass
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_message(json.loads(payload))
def send_message(self, payload: dict[str, any]) -> None:
# 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"])
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 {payload['after']['camera'].replace('_', ' ').title()}"
direct_url = f"{self.config.notifications.base_url}/review?id={reviewId}"
image = f'{self.config.notifications.base_url}{payload["after"]["thumb_path"].replace("/media/frigate", "")}'
def stop(self) -> None:
pass

View File

@ -171,7 +171,9 @@ class AuthConfig(FrigateBaseModel):
class NotificationConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable notifications")
base_url: Optional[str] = Field(default=None, title="Base url for notification link and image.")
base_url: Optional[str] = Field(
default=None, title="Base url for notification link and image."
)
class StatsConfig(FrigateBaseModel):
@ -1366,7 +1368,9 @@ class FrigateConfig(FrigateBaseModel):
default_factory=dict, title="Frigate environment variables."
)
ui: UIConfig = Field(default_factory=UIConfig, title="UI configuration.")
notifications: NotificationConfig = Field(default_factory=NotificationConfig, title="Notification Config")
notifications: NotificationConfig = Field(
default_factory=NotificationConfig, title="Notification Config"
)
telemetry: TelemetryConfig = Field(
default_factory=TelemetryConfig, title="Telemetry configuration."
)

View File

@ -32,7 +32,7 @@ SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.add_fields(
User,
notification_tokens=JSONField(default={}),
notification_tokens=JSONField(default=[]),
)

835
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -42,7 +42,6 @@
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"firebase": "^10.12.3",
"hls.js": "^1.5.13",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",

View File

@ -1,36 +0,0 @@
// Give the service worker access to Firebase Messaging.
// Note that you can only use Firebase Messaging here. Other Firebase libraries
// are not available in the service worker.
importScripts("https://www.gstatic.com/firebasejs/8.10.1/firebase-app.js");
importScripts("https://www.gstatic.com/firebasejs/8.10.1/firebase-messaging.js");
// Initialize the Firebase app in the service worker by passing in
// your app's Firebase config object.
// https://firebase.google.com/docs/web/setup#config-object
const fbConfig = await (
await fetch(`${window.location.href}/firebase-config.json`)
).json();
firebase.initializeApp(fbConfig);
// Retrieve an instance of Firebase Messaging so that it can handle background
// messages.
const messaging = firebase.messaging();
messaging.onBackgroundMessage((payload) => {
console.log(
"[firebase-messaging-sw.js] Received background message ",
payload
);
// Customize notification here
const notificationOptions = {
body: payload.notification.body,
icon: payload.data.imageUrl,
tag: payload.data.id, // ensure that the notifications for same items are written over
};
self.registration.showNotification(
payload.notification.title,
notificationOptions
);
});

View File

@ -0,0 +1,9 @@
// Notifications Worker
self.addEventListener("push", function (event) {
if (event.data) {
console.log("This push event has data: ", event.data.text());
} else {
console.log("This push event has no data.");
}
});

View File

@ -1,31 +0,0 @@
import { getMessaging } from "firebase/messaging";
import { initializeApp } from "firebase/app";
import { useEffect, useMemo, useState } from "react";
export function useFirebaseApp() {
const [firebaseConfig, setFirebaseConfig] = useState();
useEffect(() => {
if (!firebaseConfig) {
fetch(`${window.location.href}/firebase-config.json`).then(
async (resp) => {
setFirebaseConfig(await resp.json());
},
);
}
}, [firebaseConfig]);
return useMemo(() => {
if (!firebaseConfig) {
return;
}
const app = initializeApp(firebaseConfig);
app.automaticDataCollectionEnabled = false;
return app;
}, [firebaseConfig]);
}
export function useFirebaseMessaging() {
return useMemo(() => getMessaging(), []);
}

View File

@ -37,8 +37,7 @@ import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import AuthenticationView from "@/views/settings/AuthenticationView";
import NotificationView from "@/views/settings/NotificationsSettingsView";
export default function Settings() {
const settingsViews = [
const allSettingsViews = [
"general",
"camera settings",
"masks / zones",
@ -47,14 +46,28 @@ export default function Settings() {
"users",
"notifications",
] as const;
type SettingsType = (typeof allSettingsViews)[number];
type SettingsType = (typeof settingsViews)[number];
export default function Settings() {
const [page, setPage] = useState<SettingsType>("general");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const tabsRef = useRef<HTMLDivElement | null>(null);
const { data: config } = useSWR<FrigateConfig>("config");
// available settings views
const settingsViews = useMemo(() => {
const views = [...allSettingsViews];
if (!("Notification" in window) || !window.isSecureContext) {
const index = views.indexOf("notifications");
views.splice(index, 1);
}
return views;
}, []);
// TODO: confirm leave page
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);

View File

@ -341,6 +341,10 @@ export interface FrigateConfig {
user: string | null;
};
notifications: {
enabled: boolean;
};
objects: {
filters: {
[objectName: string]: {
@ -395,7 +399,7 @@ export interface FrigateConfig {
semantic_search: {
enabled: boolean;
}
};
snapshots: {
bounding_box: boolean;

View File

@ -1,21 +1,69 @@
import { Button } from "@/components/ui/button";
import { useFirebaseApp, useFirebaseMessaging } from "@/hooks/use-firebase";
import { getToken } from "firebase/messaging";
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import { Toaster } from "@/components/ui/sonner";
import { Switch } from "@/components/ui/switch";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import useSWR from "swr";
export default function NotificationView() {
useFirebaseApp();
const firebaseMessaging = useFirebaseMessaging();
const { data: config } = useSWR<FrigateConfig>("config");
return (
<>
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<Heading as="h3" className="my-2">
Notification Settings
</Heading>
<div className="mt-2 space-y-6">
<div className="space-y-3">
<div className="flex flex-row items-center justify-start gap-2">
<Switch
id="auto-live"
checked={config?.notifications?.enabled}
onCheckedChange={() => {}}
/>
<Label className="cursor-pointer" htmlFor="auto-live">
Notifications
</Label>
</div>
<div className="my-2 text-sm text-muted-foreground">
<p>
Enable notifications for Frigate alerts. This requires Frigate
to be externally accessible.
</p>
</div>
</div>
</div>
{config?.notifications.enabled && (
<div className="mt-2 space-y-6">
<div className="space-y-3">
{
// TODO need to register the worker before enabling the notifications button
// TODO make the notifications button show enable / disable depending on current state
}
<Button
onClick={() => {
Notification.requestPermission().then((permission) => {
console.log("notification permissions are ", permission);
if (permission === "granted") {
getToken(firebaseMessaging, {
vapidKey:
"BDd7XT7ElEhLApcxFvrBEs1H-6kfbmjTXhfxRIOXSWUIXOpffl_rlKHOe-qPjzp8Gyqv6tgrWX9-xwSTt2ImKPM",
}).then((token) => console.log(`the token is ${token}`));
navigator.serviceWorker
.register("notifications-worker.ts")
.then((registration) => {
registration.pushManager
.subscribe()
.then((pushSubscription) => {
console.log(pushSubscription.endpoint);
axios.post("notifications/register", {
sub: pushSubscription,
});
});
});
}
});
}}
@ -23,5 +71,10 @@ export default function NotificationView() {
Enable Notifications
</Button>
</div>
</div>
)}
</div>
</div>
</>
);
}