diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 93ab40ee7..65a0f4825 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -409,6 +409,7 @@ motion: mqtt_off_delay: 30 # Optional: Notification Configuration +# NOTE: Can be overridden at the camera level (except email) notifications: # Optional: Enable notification service (default: shown below) enabled: False diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 45d95c9f4..c344a5aaa 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -341,3 +341,19 @@ the camera to be removed from the view._ ### `frigate//birdseye_mode/state` Topic with current state of the Birdseye mode for a camera. Published values are `CONTINUOUS`, `MOTION`, `OBJECTS`. + +### `frigate//notifications/set` + +Topic to turn notifications on and off. Expected values are `ON` and `OFF`. + +### `frigate//notifications/state` + +Topic with current state of notifications. Published values are `ON` and `OFF`. + +### `frigate//notifications/suspend` + +Topic to suspend notifications for a certain number of minutes. Expected value is an integer. + +### `frigate//notifications/suspended` + +Topic with timestamp that notifications are suspended until. Published value is a UNIX timestamp, or 0 if notifications are not suspended. diff --git a/frigate/app.py b/frigate/app.py index 1aecce2c3..6ff4a1a41 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -17,8 +17,9 @@ import frigate.util as util from frigate.api.auth import hash_password from frigate.api.fastapi_app import create_fastapi_app from frigate.camera import CameraMetrics, PTZMetrics +from frigate.comms.base_communicator import Communicator from frigate.comms.config_updater import ConfigPublisher -from frigate.comms.dispatcher import Communicator, Dispatcher +from frigate.comms.dispatcher import Dispatcher from frigate.comms.event_metadata_updater import ( EventMetadataPublisher, EventMetadataTypeEnum, @@ -314,8 +315,14 @@ class FrigateApp: if self.config.mqtt.enabled: comms.append(MqttClient(self.config)) - if self.config.notifications.enabled_in_config: - comms.append(WebPushClient(self.config)) + notification_cameras = [ + c + for c in self.config.cameras.values() + if c.enabled and c.notifications.enabled_in_config + ] + + if notification_cameras: + comms.append(WebPushClient(self.config, self.stop_event)) comms.append(WebSocketClient(self.config)) comms.append(self.inter_process_communicator) diff --git a/frigate/comms/base_communicator.py b/frigate/comms/base_communicator.py new file mode 100644 index 000000000..5dfbf1115 --- /dev/null +++ b/frigate/comms/base_communicator.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod +from typing import Any, Callable + + +class Communicator(ABC): + """pub/sub model via specific protocol.""" + + @abstractmethod + def publish(self, topic: str, payload: Any, retain: bool = False) -> None: + """Send data via specific protocol.""" + pass + + @abstractmethod + def subscribe(self, receiver: Callable) -> None: + """Pass receiver so communicators can pass commands.""" + pass + + @abstractmethod + def stop(self) -> None: + """Stop the communicator.""" + pass diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 445147df8..e0c2d96e3 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -3,17 +3,19 @@ import datetime import json import logging -from abc import ABC, abstractmethod from typing import Any, Callable, Optional from frigate.camera import PTZMetrics from frigate.camera.activity_manager import CameraActivityManager +from frigate.comms.base_communicator import Communicator from frigate.comms.config_updater import ConfigPublisher +from frigate.comms.webpush import WebPushClient from frigate.config import BirdseyeModeEnum, FrigateConfig from frigate.const import ( CLEAR_ONGOING_REVIEW_SEGMENTS, INSERT_MANY_RECORDINGS, INSERT_PREVIEW, + NOTIFICATION_TEST, REQUEST_REGION_GRID, UPDATE_CAMERA_ACTIVITY, UPDATE_EMBEDDINGS_REINDEX_PROGRESS, @@ -30,25 +32,6 @@ from frigate.util.services import restart_frigate logger = logging.getLogger(__name__) -class Communicator(ABC): - """pub/sub model via specific protocol.""" - - @abstractmethod - def publish(self, topic: str, payload: Any, retain: bool = False) -> None: - """Send data via specific protocol.""" - pass - - @abstractmethod - def subscribe(self, receiver: Callable) -> None: - """Pass receiver so communicators can pass commands.""" - pass - - @abstractmethod - def stop(self) -> None: - """Stop the communicator.""" - pass - - class Dispatcher: """Handle communication between Frigate and communicators.""" @@ -77,18 +60,23 @@ class Dispatcher: "motion": self._on_motion_command, "motion_contour_area": self._on_motion_contour_area_command, "motion_threshold": self._on_motion_threshold_command, + "notifications": self._on_camera_notification_command, "recordings": self._on_recordings_command, "snapshots": self._on_snapshots_command, "birdseye": self._on_birdseye_command, "birdseye_mode": self._on_birdseye_mode_command, } self._global_settings_handlers: dict[str, Callable] = { - "notifications": self._on_notification_command, + "notifications": self._on_global_notification_command, } for comm in self.comms: comm.subscribe(self._receive) + self.web_push_client = next( + (comm for comm in communicators if isinstance(comm, WebPushClient)), None + ) + def _receive(self, topic: str, payload: str) -> Optional[Any]: """Handle receiving of payload from communicators.""" @@ -180,6 +168,13 @@ class Dispatcher: "snapshots": self.config.cameras[camera].snapshots.enabled, "record": self.config.cameras[camera].record.enabled, "audio": self.config.cameras[camera].audio.enabled, + "notifications": self.config.cameras[camera].notifications.enabled, + "notifications_suspended": int( + self.web_push_client.suspended_cameras.get(camera, 0) + ) + if self.web_push_client + and camera in self.web_push_client.suspended_cameras + else 0, "autotracking": self.config.cameras[ camera ].onvif.autotracking.enabled, @@ -192,6 +187,9 @@ class Dispatcher: json.dumps(self.embeddings_reindex.copy()), ) + def handle_notification_test(): + self.publish("notification_test", "Test notification") + # Dictionary mapping topic to handlers topic_handlers = { INSERT_MANY_RECORDINGS: handle_insert_many_recordings, @@ -203,13 +201,14 @@ class Dispatcher: UPDATE_EVENT_DESCRIPTION: handle_update_event_description, UPDATE_MODEL_STATE: handle_update_model_state, UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress, + NOTIFICATION_TEST: handle_notification_test, "restart": handle_restart, "embeddingsReindexProgress": handle_embeddings_reindex_progress, "modelState": handle_model_state, "onConnect": handle_on_connect, } - if topic.endswith("set") or topic.endswith("ptz"): + if topic.endswith("set") or topic.endswith("ptz") or topic.endswith("suspend"): try: parts = topic.split("/") if len(parts) == 3 and topic.endswith("set"): @@ -224,6 +223,11 @@ class Dispatcher: # example /cam_name/ptz payload=MOVE_UP|MOVE_DOWN|STOP... camera_name = parts[-2] handle_camera_command("ptz", camera_name, "", payload) + elif len(parts) == 3 and topic.endswith("suspend"): + # example /cam_name/notifications/suspend payload=duration + camera_name = parts[-3] + command = parts[-2] + self._on_camera_notification_suspend(camera_name, payload) except IndexError: logger.error( f"Received invalid {topic.split('/')[-1]} command: {topic}" @@ -365,16 +369,18 @@ class Dispatcher: self.config_updater.publish(f"config/motion/{camera_name}", motion_settings) self.publish(f"{camera_name}/motion_threshold/state", payload, retain=True) - def _on_notification_command(self, payload: str) -> None: - """Callback for notification topic.""" + def _on_global_notification_command(self, payload: str) -> None: + """Callback for global notification topic.""" if payload != "ON" and payload != "OFF": - f"Received unsupported value for notification: {payload}" + f"Received unsupported value for all notification: {payload}" return notification_settings = self.config.notifications - logger.info(f"Setting notifications: {payload}") + logger.info(f"Setting all notifications: {payload}") notification_settings.enabled = payload == "ON" # type: ignore[union-attr] - self.config_updater.publish("config/notifications", notification_settings) + self.config_updater.publish( + "config/notifications", {"_global_notifications": notification_settings} + ) self.publish("notifications/state", payload, retain=True) def _on_audio_command(self, camera_name: str, payload: str) -> None: @@ -491,3 +497,71 @@ class Dispatcher: self.config_updater.publish(f"config/birdseye/{camera_name}", birdseye_settings) self.publish(f"{camera_name}/birdseye_mode/state", payload, retain=True) + + def _on_camera_notification_command(self, camera_name: str, payload: str) -> None: + """Callback for camera level notifications topic.""" + notification_settings = self.config.cameras[camera_name].notifications + + if payload == "ON": + if not self.config.cameras[camera_name].notifications.enabled_in_config: + logger.error( + "Notifications must be enabled in the config to be turned on via MQTT." + ) + return + + if not notification_settings.enabled: + logger.info(f"Turning on notifications for {camera_name}") + notification_settings.enabled = True + if ( + self.web_push_client + and camera_name in self.web_push_client.suspended_cameras + ): + self.web_push_client.suspended_cameras[camera_name] = 0 + elif payload == "OFF": + if notification_settings.enabled: + logger.info(f"Turning off notifications for {camera_name}") + notification_settings.enabled = False + if ( + self.web_push_client + and camera_name in self.web_push_client.suspended_cameras + ): + self.web_push_client.suspended_cameras[camera_name] = 0 + + self.config_updater.publish( + "config/notifications", {camera_name: notification_settings} + ) + self.publish(f"{camera_name}/notifications/state", payload, retain=True) + self.publish(f"{camera_name}/notifications/suspended", "0", retain=True) + + def _on_camera_notification_suspend(self, camera_name: str, payload: str) -> None: + """Callback for camera level notifications suspend topic.""" + try: + duration = int(payload) + except ValueError: + logger.error(f"Invalid suspension duration: {payload}") + return + + if self.web_push_client is None: + logger.error("WebPushClient not available for suspension") + return + + notification_settings = self.config.cameras[camera_name].notifications + + if not notification_settings.enabled: + logger.error(f"Notifications are not enabled for {camera_name}") + return + + if duration != 0: + self.web_push_client.suspend_notifications(camera_name, duration) + else: + self.web_push_client.unsuspend_notifications(camera_name) + + self.publish( + f"{camera_name}/notifications/suspended", + str( + int(self.web_push_client.suspended_cameras.get(camera_name, 0)) + if camera_name in self.web_push_client.suspended_cameras + else 0 + ), + retain=True, + ) diff --git a/frigate/comms/inter_process.py b/frigate/comms/inter_process.py index 850e2435c..36a6857a4 100644 --- a/frigate/comms/inter_process.py +++ b/frigate/comms/inter_process.py @@ -7,7 +7,7 @@ from typing import Callable import zmq -from frigate.comms.dispatcher import Communicator +from frigate.comms.base_communicator import Communicator SOCKET_REP_REQ = "ipc:///tmp/cache/comms" diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index 33478d5c4..57460b29b 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -5,7 +5,7 @@ from typing import Any, Callable import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion -from frigate.comms.dispatcher import Communicator +from frigate.comms.base_communicator import Communicator from frigate.config import FrigateConfig logger = logging.getLogger(__name__) diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py index abfd52d19..b96b6f3c0 100644 --- a/frigate/comms/webpush.py +++ b/frigate/comms/webpush.py @@ -4,13 +4,17 @@ import datetime import json import logging import os +import queue +import threading +from dataclasses import dataclass +from multiprocessing.synchronize import Event as MpEvent from typing import Any, Callable from py_vapid import Vapid01 from pywebpush import WebPusher +from frigate.comms.base_communicator import Communicator from frigate.comms.config_updater import ConfigSubscriber -from frigate.comms.dispatcher import Communicator from frigate.config import FrigateConfig from frigate.const import CONFIG_DIR from frigate.models import User @@ -18,15 +22,36 @@ from frigate.models import User logger = logging.getLogger(__name__) +@dataclass +class PushNotification: + user: str + payload: dict[str, Any] + title: str + message: str + direct_url: str = "" + image: str = "" + notification_type: str = "alert" + ttl: int = 0 + + class WebPushClient(Communicator): # type: ignore[misc] """Frigate wrapper for webpush client.""" - def __init__(self, config: FrigateConfig) -> None: + def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> 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]] = {} + self.suspended_cameras: dict[str, int] = { + c.name: 0 for c in self.config.cameras.values() + } + self.notification_queue: queue.Queue[PushNotification] = queue.Queue() + self.notification_thread = threading.Thread( + target=self._process_notifications, daemon=True + ) + self.notification_thread.start() + self.stop_event = stop_event if not self.config.notifications.email: logger.warning("Email must be provided for push notifications to be sent.") @@ -103,30 +128,144 @@ class WebPushClient(Communicator): # type: ignore[misc] self.expired_subs = {} + def suspend_notifications(self, camera: str, minutes: int) -> None: + """Suspend notifications for a specific camera.""" + suspend_until = int( + (datetime.datetime.now() + datetime.timedelta(minutes=minutes)).timestamp() + ) + self.suspended_cameras[camera] = suspend_until + logger.info( + f"Notifications for {camera} suspended until {datetime.datetime.fromtimestamp(suspend_until).strftime('%Y-%m-%d %H:%M:%S')}" + ) + + def unsuspend_notifications(self, camera: str) -> None: + """Unsuspend notifications for a specific camera.""" + self.suspended_cameras[camera] = 0 + logger.info(f"Notifications for {camera} unsuspended") + + def is_camera_suspended(self, camera: str) -> bool: + return datetime.datetime.now().timestamp() <= self.suspended_cameras[camera] + def publish(self, topic: str, payload: Any, retain: bool = False) -> None: """Wrapper for publishing when client is in valid state.""" # check for updated notification config _, updated_notification_config = self.config_subscriber.check_for_update() if updated_notification_config: - self.config.notifications = updated_notification_config + for key, value in updated_notification_config.items(): + if key == "_global_notifications": + self.config.notifications = value - if not self.config.notifications.enabled: - return + elif key in self.config.cameras: + self.config.cameras[key].notifications = value if topic == "reviews": - self.send_alert(json.loads(payload)) + decoded = json.loads(payload) + camera = decoded["before"]["camera"] + if not self.config.cameras[camera].notifications.enabled: + return + if self.is_camera_suspended(camera): + logger.debug(f"Notifications for {camera} are currently suspended.") + return + self.send_alert(decoded) + elif topic == "notification_test": + if not self.config.notifications.enabled: + return + self.send_notification_test() - def send_alert(self, payload: dict[str, any]) -> None: + def send_push_notification( + self, + user: str, + payload: dict[str, Any], + title: str, + message: str, + direct_url: str = "", + image: str = "", + notification_type: str = "alert", + ttl: int = 0, + ) -> None: + notification = PushNotification( + user=user, + payload=payload, + title=title, + message=message, + direct_url=direct_url, + image=image, + notification_type=notification_type, + ttl=ttl, + ) + self.notification_queue.put(notification) + + def _process_notifications(self) -> None: + while not self.stop_event.is_set(): + try: + notification = self.notification_queue.get(timeout=1.0) + self.check_registrations() + + for pusher in self.web_pushers[notification.user]: + endpoint = pusher.subscription_info["endpoint"] + headers = self.claim_headers[ + endpoint[: endpoint.index("/", 10)] + ].copy() + headers["urgency"] = "high" + + resp = pusher.send( + headers=headers, + ttl=notification.ttl, + data=json.dumps( + { + "title": notification.title, + "message": notification.message, + "direct_url": notification.direct_url, + "image": notification.image, + "id": notification.payload.get("after", {}).get( + "id", "" + ), + "type": notification.notification_type, + } + ), + timeout=10, + ) + + if resp.status_code in (404, 410): + self.expired_subs.setdefault(notification.user, []).append( + endpoint + ) + elif resp.status_code != 201: + logger.warning( + f"Failed to send notification to {notification.user} :: {resp.status_code}" + ) + + except queue.Empty: + continue + except Exception as e: + logger.error(f"Error processing notification: {str(e)}") + + def send_notification_test(self) -> None: if not self.config.notifications.email: return self.check_registrations() - # Only notify for alerts - if payload["after"]["severity"] != "alert": + for user in self.web_pushers: + self.send_push_notification( + user=user, + payload={}, + title="Test Notification", + message="This is a test notification from Frigate.", + direct_url="/", + notification_type="test", + ) + + def send_alert(self, payload: dict[str, Any]) -> None: + if ( + not self.config.notifications.email + or payload["after"]["severity"] != "alert" + ): return + self.check_registrations() + state = payload["type"] # Don't notify if message is an update and important fields don't have an update @@ -155,49 +294,21 @@ class WebPushClient(Communicator): # type: ignore[misc] # 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}" + ttl = 3600 if state == "end" else 0 - 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, - "type": "alert", - } - ), - ) - - 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}" - ) + for user in self.web_pushers: + self.send_push_notification( + user=user, + payload=payload, + title=title, + message=message, + direct_url=direct_url, + image=image, + ttl=ttl, + ) self.cleanup_registrations() def stop(self) -> None: - pass + logger.info("Closing notification queue") + self.notification_thread.join() diff --git a/frigate/comms/ws.py b/frigate/comms/ws.py index fccd8db5c..1eed290f7 100644 --- a/frigate/comms/ws.py +++ b/frigate/comms/ws.py @@ -15,7 +15,7 @@ from ws4py.server.wsgirefserver import ( from ws4py.server.wsgiutils import WebSocketWSGIApplication from ws4py.websocket import WebSocket as WebSocket_ -from frigate.comms.dispatcher import Communicator +from frigate.comms.base_communicator import Communicator from frigate.config import FrigateConfig logger = logging.getLogger(__name__) diff --git a/frigate/config/__init__.py b/frigate/config/__init__.py index e90c336e5..c6ff535b0 100644 --- a/frigate/config/__init__.py +++ b/frigate/config/__init__.py @@ -8,7 +8,6 @@ from .config import * # noqa: F403 from .database import * # noqa: F403 from .logger import * # noqa: F403 from .mqtt import * # noqa: F403 -from .notification import * # noqa: F403 from .proxy import * # noqa: F403 from .telemetry import * # noqa: F403 from .tls import * # noqa: F403 diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index 69fa1b455..50f61f33c 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -25,6 +25,7 @@ from .genai import GenAICameraConfig from .live import CameraLiveConfig from .motion import MotionConfig from .mqtt import CameraMqttConfig +from .notification import NotificationConfig from .objects import ObjectConfig from .onvif import OnvifConfig from .record import RecordConfig @@ -85,6 +86,9 @@ class CameraConfig(FrigateBaseModel): mqtt: CameraMqttConfig = Field( default_factory=CameraMqttConfig, title="MQTT configuration." ) + notifications: NotificationConfig = Field( + default_factory=NotificationConfig, title="Notifications configuration." + ) onvif: OnvifConfig = Field( default_factory=OnvifConfig, title="Camera Onvif Configuration." ) diff --git a/frigate/config/notification.py b/frigate/config/camera/notification.py similarity index 92% rename from frigate/config/notification.py rename to frigate/config/camera/notification.py index 0ffebff3c..79355b8ae 100644 --- a/frigate/config/notification.py +++ b/frigate/config/camera/notification.py @@ -2,7 +2,7 @@ from typing import Optional from pydantic import Field -from .base import FrigateBaseModel +from ..base import FrigateBaseModel __all__ = ["NotificationConfig"] diff --git a/frigate/config/config.py b/frigate/config/config.py index 694a3389f..aea41a7bc 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -46,6 +46,7 @@ from .camera.detect import DetectConfig from .camera.ffmpeg import FfmpegConfig from .camera.genai import GenAIConfig from .camera.motion import MotionConfig +from .camera.notification import NotificationConfig from .camera.objects import FilterConfig, ObjectConfig from .camera.record import RecordConfig, RetainModeEnum from .camera.review import ReviewConfig @@ -62,7 +63,6 @@ from .database import DatabaseConfig from .env import EnvVars from .logger import LoggerConfig from .mqtt import MqttConfig -from .notification import NotificationConfig from .proxy import ProxyConfig from .telemetry import TelemetryConfig from .tls import TlsConfig @@ -332,7 +332,7 @@ class FrigateConfig(FrigateBaseModel): ) mqtt: MqttConfig = Field(title="MQTT configuration.") notifications: NotificationConfig = Field( - default_factory=NotificationConfig, title="Notification configuration." + default_factory=NotificationConfig, title="Global notification configuration." ) proxy: ProxyConfig = Field( default_factory=ProxyConfig, title="Proxy configuration." @@ -452,6 +452,7 @@ class FrigateConfig(FrigateBaseModel): "review": ..., "genai": ..., "motion": ..., + "notifications": ..., "detect": ..., "ffmpeg": ..., "timestamp_style": ..., @@ -527,6 +528,9 @@ class FrigateConfig(FrigateBaseModel): # set config pre-value camera_config.audio.enabled_in_config = camera_config.audio.enabled camera_config.record.enabled_in_config = camera_config.record.enabled + camera_config.notifications.enabled_in_config = ( + camera_config.notifications.enabled + ) camera_config.onvif.autotracking.enabled_in_config = ( camera_config.onvif.autotracking.enabled ) diff --git a/frigate/const.py b/frigate/const.py index 559d7552f..16df8b887 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -104,6 +104,7 @@ UPDATE_CAMERA_ACTIVITY = "update_camera_activity" UPDATE_EVENT_DESCRIPTION = "update_event_description" UPDATE_MODEL_STATE = "update_model_state" UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress" +NOTIFICATION_TEST = "notification_test" # Stats Values diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 9b8924d1b..3ac7c9fee 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -53,13 +53,26 @@ function useValue(): useValueReturn { const cameraStates: WsState = {}; Object.entries(cameraActivity).forEach(([name, state]) => { - const { record, detect, snapshots, audio, autotracking } = + const { + record, + detect, + snapshots, + audio, + notifications, + notifications_suspended, + autotracking, + } = // @ts-expect-error we know this is correct state["config"]; cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF"; cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF"; cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF"; cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF"; + cameraStates[`${name}/notifications/state`] = notifications + ? "ON" + : "OFF"; + cameraStates[`${name}/notifications/suspended`] = + notifications_suspended || 0; cameraStates[`${name}/ptz_autotracker/state`] = autotracking ? "ON" : "OFF"; @@ -413,3 +426,39 @@ export function useTrackedObjectUpdate(): { payload: string } { } = useWs("tracked_object_update", ""); return useDeepMemo(JSON.parse(payload as string)); } + +export function useNotifications(camera: string): { + payload: ToggleableSetting; + send: (payload: string, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs(`${camera}/notifications/state`, `${camera}/notifications/set`); + return { payload: payload as ToggleableSetting, send }; +} + +export function useNotificationSuspend(camera: string): { + payload: string; + send: (payload: number, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/notifications/suspended`, + `${camera}/notifications/suspend`, + ); + return { payload: payload as string, send }; +} + +export function useNotificationTest(): { + payload: string; + send: (payload: string, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs("notification_test", "notification_test"); + return { payload: payload as string, send }; +} diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 794683adf..073308d58 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -111,6 +111,11 @@ export interface CameraConfig { timestamp: boolean; }; name: string; + notifications: { + enabled: boolean; + email?: string; + enabled_in_config: boolean; + }; objects: { filters: { [objectName: string]: { @@ -393,6 +398,7 @@ export interface FrigateConfig { notifications: { enabled: boolean; email?: string; + enabled_in_config: boolean; }; objects: { diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index 751817245..5ea545fb3 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -14,24 +14,38 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Toaster } from "@/components/ui/sonner"; -import { Switch } from "@/components/ui/switch"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { FrigateConfig } from "@/types/frigateConfig"; import { zodResolver } from "@hookform/resolvers/zod"; import axios from "axios"; -import { useCallback, useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; -import { LuExternalLink } from "react-icons/lu"; +import { LuCheck, LuExternalLink, LuX } from "react-icons/lu"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import useSWR from "swr"; import { z } from "zod"; +import { + useNotifications, + useNotificationSuspend, + useNotificationTest, +} from "@/api/ws"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import FilterSwitch from "@/components/filter/FilterSwitch"; const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js"; type NotificationSettingsValueType = { - enabled: boolean; + allEnabled: boolean; email?: string; + cameras: string[]; }; type NotificationsSettingsViewProps = { @@ -47,9 +61,52 @@ export default function NotificationView({ }, ); + const allCameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.values(config.cameras).sort( + (aConf, bConf) => aConf.ui.order - bConf.ui.order, + ); + }, [config]); + + const notificationCameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.values(config.cameras) + .filter( + (conf) => + conf.enabled && + conf.notifications && + conf.notifications.enabled_in_config, + ) + .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); + }, [config]); + + const { send: sendTestNotification } = useNotificationTest(); + // status bar const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; + const [changedValue, setChangedValue] = useState(false); + + useEffect(() => { + if (changedValue) { + addMessage( + "notification_settings", + `Unsaved notification settings`, + undefined, + `notification_settings`, + ); + } else { + removeMessage("notification_settings", `notification_settings`); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changedValue]); // notification key handling @@ -87,7 +144,7 @@ export default function NotificationView({ setRegistration(null); }); toast.success( - "Successfully registered for notifications. Restart to start receiving notifications.", + "Successfully registered for notifications. Restarting Frigate is required before any notifications (including a test notification) can be sent.", { position: "top-center", }, @@ -122,28 +179,44 @@ export default function NotificationView({ const [isLoading, setIsLoading] = useState(false); const formSchema = z.object({ - enabled: z.boolean(), + allEnabled: z.boolean(), email: z.string(), + cameras: z.array(z.string()), }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues: { - enabled: config?.notifications.enabled, + allEnabled: config?.notifications.enabled, email: config?.notifications.email, + cameras: config?.notifications.enabled + ? [] + : notificationCameras.map((c) => c.name), }, }); + const watchCameras = form.watch("cameras"); + + useEffect(() => { + if (watchCameras.length > 0) { + form.setValue("allEnabled", false); + } + }, [watchCameras, allCameras, form]); + const onCancel = useCallback(() => { if (!config) { return; } setUnsavedChanges(false); + setChangedValue(false); form.reset({ - enabled: config.notifications.enabled, + allEnabled: config.notifications.enabled, email: config.notifications.email || "", + cameras: config?.notifications.enabled + ? [] + : notificationCameras.map((c) => c.name), }); // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps @@ -151,11 +224,27 @@ export default function NotificationView({ const saveToConfig = useCallback( async ( - { enabled, email }: NotificationSettingsValueType, // values submitted via the form + { allEnabled, email, cameras }: NotificationSettingsValueType, // values submitted via the form ) => { + const allCameraNames = allCameras.map((cam) => cam.name); + + const enabledCameraQueries = cameras + .map((cam) => `&cameras.${cam}.notifications.enabled=True`) + .join(""); + + const disabledCameraQueries = allCameraNames + .filter((cam) => !cameras.includes(cam)) + .map( + (cam) => + `&cameras.${cam}.notifications.enabled=${allEnabled ? "True" : "False"}`, + ) + .join(""); + + const allCameraQueries = enabledCameraQueries + disabledCameraQueries; + axios .put( - `config/set?notifications.enabled=${enabled}¬ifications.email=${email}`, + `config/set?notifications.enabled=${allEnabled ? "True" : "False"}¬ifications.email=${email}${allCameraQueries}`, { requires_restart: 0, }, @@ -182,7 +271,7 @@ export default function NotificationView({ setIsLoading(false); }); }, - [updateConfig, setIsLoading], + [updateConfig, setIsLoading, allCameras], ); function onSubmit(values: z.infer) { @@ -195,149 +284,249 @@ export default function NotificationView({
- - Notification Settings - +
+
+ + Notification Settings + -
-
-

- Frigate can natively send push notifications to your device when - it is running in the browser or installed as a PWA. -

-
- - Read the Documentation{" "} - - +
+
+

+ Frigate can natively send push notifications to your device + when it is running in the browser or installed as a PWA. +

+
+ + Read the Documentation{" "} + + +
+
+ +
+ + ( + + Email + + + + + Entering a valid email is required, as this is used by + the push server in case problems occur. + + + + )} + /> + + ( + + {allCameras && allCameras?.length > 0 ? ( + <> +
+ + Cameras + +
+
+ ( + { + setChangedValue(true); + if (checked) { + form.setValue("cameras", []); + } + field.onChange(checked); + }} + /> + )} + /> + {allCameras?.map((camera) => ( + { + setChangedValue(true); + let newCameras; + if (checked) { + newCameras = [ + ...field.value, + camera.name, + ]; + } else { + newCameras = field.value?.filter( + (value) => value !== camera.name, + ); + } + field.onChange(newCameras); + form.setValue("allEnabled", false); + }} + /> + ))} +
+ + ) : ( +
+ No cameras available. +
+ )} + + + + Select the cameras to enable notifications for. + +
+ )} + /> + +
+ + +
+ +
-
-
- - ( - - -
- - { - return field.onChange(checked); - }} - /> -
-
-
- )} - /> - ( - - Email - - - - - Entering a valid email is required, as this is used by the - push server in case problems occur. - - - - )} - /> -
- - -
- - +
+
+
+ + + Device-Specific Settings + + + }} + > + {`${registration != null ? "Unregister" : "Register"} for notifications on this device`} + + {registration != null && registration.active && ( + + )} +
+
+ {notificationCameras.length > 0 && ( +
+
+ + + Global Settings + +
+
+

+ Temporarily suspend notifications for specific cameras + on all registered devices. +

+
+
+ +
+
+
+ {notificationCameras.map((item) => ( + + ))} +
+
+
+
+
+ )}
@@ -345,3 +534,110 @@ export default function NotificationView({ ); } + +type CameraNotificationSwitchProps = { + config?: FrigateConfig; + camera: string; +}; + +export function CameraNotificationSwitch({ + config, + camera, +}: CameraNotificationSwitchProps) { + const { payload: notificationState, send: sendNotification } = + useNotifications(camera); + const { payload: notificationSuspendUntil, send: sendNotificationSuspend } = + useNotificationSuspend(camera); + const [isSuspended, setIsSuspended] = useState(false); + + useEffect(() => { + if (notificationSuspendUntil) { + setIsSuspended( + notificationSuspendUntil !== "0" || notificationState === "OFF", + ); + } + }, [notificationSuspendUntil, notificationState]); + + const handleSuspend = (duration: string) => { + setIsSuspended(true); + if (duration == "off") { + sendNotification("OFF"); + } else { + sendNotificationSuspend(parseInt(duration)); + } + }; + + const handleCancelSuspension = () => { + sendNotification("ON"); + sendNotificationSuspend(0); + }; + + const formatSuspendedUntil = (timestamp: string) => { + if (timestamp === "0") return "Frigate restarts."; + + return formatUnixTimestampToDateTime(parseInt(timestamp), { + time_style: "medium", + date_style: "medium", + timezone: config?.ui.timezone, + strftime_fmt: `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`, + }); + }; + + return ( +
+
+
+ {!isSuspended ? ( + + ) : ( + + )} +
+ + + {!isSuspended ? ( +
+ Notifications Active +
+ ) : ( +
+ Notifications suspended until{" "} + {formatSuspendedUntil(notificationSuspendUntil)} +
+ )} +
+
+
+ + {!isSuspended ? ( + + ) : ( + + )} +
+ ); +}