mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-13 06:35:24 +03:00
Implement saving of notification tokens
This commit is contained in:
parent
fbc35a36b1
commit
d40f177b77
@ -37,5 +37,3 @@ chromadb == 0.5.0
|
||||
google-generativeai == 0.6.*
|
||||
ollama == 0.2.*
|
||||
openai == 1.30.*
|
||||
# Notifications
|
||||
firebase_admin == 6.5.0
|
||||
@ -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(
|
||||
|
||||
34
frigate/api/notification.py
Normal file
34
frigate/api/notification.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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
70
frigate/comms/webpush.py
Normal 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
|
||||
@ -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."
|
||||
)
|
||||
|
||||
@ -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
835
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
9
web/public/notifications-worker.ts
Normal file
9
web/public/notifications-worker.ts
Normal 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.");
|
||||
}
|
||||
});
|
||||
@ -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(), []);
|
||||
}
|
||||
@ -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",
|
||||
@ -46,15 +45,29 @@ export default function Settings() {
|
||||
"debug",
|
||||
"users",
|
||||
"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 [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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user