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.* google-generativeai == 0.6.*
ollama == 0.2.* ollama == 0.2.*
openai == 1.30.* 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.event import EventBp
from frigate.api.export import ExportBp from frigate.api.export import ExportBp
from frigate.api.media import MediaBp from frigate.api.media import MediaBp
from frigate.api.notification import NotificationBp
from frigate.api.preview import PreviewBp from frigate.api.preview import PreviewBp
from frigate.api.review import ReviewBp from frigate.api.review import ReviewBp
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
@ -48,6 +49,7 @@ bp.register_blueprint(MediaBp)
bp.register_blueprint(PreviewBp) bp.register_blueprint(PreviewBp)
bp.register_blueprint(ReviewBp) bp.register_blueprint(ReviewBp)
bp.register_blueprint(AuthBp) bp.register_blueprint(AuthBp)
bp.register_blueprint(NotificationBp)
def create_app( 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.api.auth import hash_password
from frigate.comms.config_updater import ConfigPublisher from frigate.comms.config_updater import ConfigPublisher
from frigate.comms.dispatcher import Communicator, Dispatcher from frigate.comms.dispatcher import Communicator, Dispatcher
from frigate.comms.firebase import FirebaseClient
from frigate.comms.inter_process import InterProcessCommunicator from frigate.comms.inter_process import InterProcessCommunicator
from frigate.comms.mqtt import MqttClient from frigate.comms.mqtt import MqttClient
from frigate.comms.webpush import WebPushClient
from frigate.comms.ws import WebSocketClient from frigate.comms.ws import WebSocketClient
from frigate.comms.zmq_proxy import ZmqProxy from frigate.comms.zmq_proxy import ZmqProxy
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
@ -403,7 +403,7 @@ class FrigateApp:
comms.append(MqttClient(self.config)) comms.append(MqttClient(self.config))
if self.config.notifications.enabled: 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(WebSocketClient(self.config))
comms.append(self.inter_process_communicator) 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): class NotificationConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable notifications") 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): class StatsConfig(FrigateBaseModel):
@ -1366,7 +1368,9 @@ class FrigateConfig(FrigateBaseModel):
default_factory=dict, title="Frigate environment variables." default_factory=dict, title="Frigate environment variables."
) )
ui: UIConfig = Field(default_factory=UIConfig, title="UI configuration.") 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( telemetry: TelemetryConfig = Field(
default_factory=TelemetryConfig, title="Telemetry configuration." default_factory=TelemetryConfig, title="Telemetry configuration."
) )

View File

@ -32,7 +32,7 @@ SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs): def migrate(migrator, database, fake=False, **kwargs):
migrator.add_fields( migrator.add_fields(
User, 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", "clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"firebase": "^10.12.3",
"hls.js": "^1.5.13", "hls.js": "^1.5.13",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.1.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 AuthenticationView from "@/views/settings/AuthenticationView";
import NotificationView from "@/views/settings/NotificationsSettingsView"; import NotificationView from "@/views/settings/NotificationsSettingsView";
export default function Settings() { const allSettingsViews = [
const settingsViews = [
"general", "general",
"camera settings", "camera settings",
"masks / zones", "masks / zones",
@ -46,15 +45,29 @@ export default function Settings() {
"debug", "debug",
"users", "users",
"notifications", "notifications",
] as const; ] as const;
type SettingsType = (typeof allSettingsViews)[number];
type SettingsType = (typeof settingsViews)[number]; export default function Settings() {
const [page, setPage] = useState<SettingsType>("general"); const [page, setPage] = useState<SettingsType>("general");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const tabsRef = useRef<HTMLDivElement | null>(null); const tabsRef = useRef<HTMLDivElement | null>(null);
const { data: config } = useSWR<FrigateConfig>("config"); 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 // TODO: confirm leave page
const [unsavedChanges, setUnsavedChanges] = useState(false); const [unsavedChanges, setUnsavedChanges] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);

View File

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

View File

@ -1,21 +1,69 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useFirebaseApp, useFirebaseMessaging } from "@/hooks/use-firebase"; import Heading from "@/components/ui/heading";
import { getToken } from "firebase/messaging"; 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() { export default function NotificationView() {
useFirebaseApp(); const { data: config } = useSWR<FrigateConfig>("config");
const firebaseMessaging = useFirebaseMessaging();
return ( return (
<>
<div className="flex size-full flex-col md:flex-row"> <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 <Button
onClick={() => { onClick={() => {
Notification.requestPermission().then((permission) => { Notification.requestPermission().then((permission) => {
console.log("notification permissions are ", permission);
if (permission === "granted") { if (permission === "granted") {
getToken(firebaseMessaging, { navigator.serviceWorker
vapidKey: .register("notifications-worker.ts")
"BDd7XT7ElEhLApcxFvrBEs1H-6kfbmjTXhfxRIOXSWUIXOpffl_rlKHOe-qPjzp8Gyqv6tgrWX9-xwSTt2ImKPM", .then((registration) => {
}).then((token) => console.log(`the token is ${token}`)); 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 Enable Notifications
</Button> </Button>
</div> </div>
</div>
)}
</div>
</div>
</>
); );
} }