Files
frigate/frigate/comms/webpush.py
T
Josh Hawkins f0d7c1d7d4 Guard lookups when adding/deleting cameras at runtime (#23994)
* Guard object processor queue handlers against unknown cameras

* Skip embeddings post processing for removed cameras

* End review segments for removed cameras

* Drop queued autotracker moves for removed cameras

* Release tracked event thumbnails when skipping a removed camera

* Add locked accessors for camera states

* Read camera states through the processor accessors

* Guard output and recording paths against cameras not yet known

* Resolve camera state once in ONVIF, notification, and transcription paths
2026-08-27 20:30:35 -05:00

612 lines
22 KiB
Python

"""Handle sending notifications for Frigate via Firebase."""
import datetime
import json
import logging
import os
import queue
import threading
from collections.abc import Callable
from dataclasses import dataclass
from multiprocessing.synchronize import Event as MpEvent
from typing import Any
from py_vapid import Vapid01
from pywebpush import WebPusher
from titlecase import titlecase
from frigate.comms.base_communicator import Communicator
from frigate.comms.config_updater import ConfigSubscriber
from frigate.config import FrigateConfig
from frigate.config.auth import AuthConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
from frigate.const import BASE_DIR, CONFIG_DIR
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):
"""Frigate wrapper for webpush client."""
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
self.config = config
self.stop_event = stop_event
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 # type: ignore[misc]
for c in self.config.cameras.values()
}
self.suspension_broadcaster: Callable[[str, Any, bool], None] | None = None
self.last_camera_notification_time: dict[str, float] = {
c.name: 0 # type: ignore[misc]
for c in self.config.cameras.values()
}
self.last_notification_time: float = 0
self.user_cameras: dict[str, set[str]] = {}
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
self.notification_thread = threading.Thread(
target=self._process_notifications, daemon=True
)
self.notification_thread.start()
self.suspension_thread = threading.Thread(
target=self._process_suspensions, daemon=True
)
self.suspension_thread.start()
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[dict[str, Any]] = (
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))
# notification and auth config updater
self.global_config_subscriber = ConfigSubscriber("config/")
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config,
self.config.cameras,
[CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.notifications],
)
self._refresh_user_cameras()
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 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 set_suspension_broadcaster(
self, broadcaster: Callable[[str, Any, bool], None]
) -> None:
"""Register the callback used to broadcast suspension state changes."""
self.suspension_broadcaster = broadcaster
def _process_suspensions(self) -> None:
while not self.stop_event.wait(1):
self._clear_expired_suspensions()
def _clear_expired_suspensions(self) -> None:
"""Reset and broadcast cameras whose suspension window has elapsed."""
now = datetime.datetime.now().timestamp()
for camera, suspended_until in list(self.suspended_cameras.items()):
if suspended_until and now > suspended_until:
self.unsuspend_notifications(camera)
if self.suspension_broadcaster is not None:
self.suspension_broadcaster(
f"{camera}/notifications/suspended", "0", True
)
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state."""
# check for updated global config (notifications, auth)
while True:
config_topic, config_payload = (
self.global_config_subscriber.check_for_update()
)
if config_topic is None:
break
if config_topic == "config/notifications" and config_payload:
self.config.notifications = config_payload
elif config_topic == "config/auth":
if isinstance(config_payload, AuthConfig):
self.config.auth = config_payload
self._refresh_user_cameras()
updates = self.config_subscriber.check_for_updates()
if "add" in updates:
for camera in updates["add"]:
self.suspended_cameras[camera] = 0
self.last_camera_notification_time[camera] = 0
self._refresh_user_cameras()
if topic == "reviews":
decoded = json.loads(payload)
camera = decoded["before"]["camera"]
camera_config = self.config.cameras.get(camera)
if camera_config is None or not camera_config.notifications.enabled:
return
if self.is_camera_suspended(camera):
logger.debug(f"Notifications for {camera} are currently suspended.")
return
self.send_alert(decoded)
if topic == "triggers":
decoded = json.loads(payload)
camera = decoded["camera"]
name = decoded["name"]
# ensure notifications are enabled and the specific trigger has
# notification action enabled
camera_config = self.config.cameras.get(camera)
if (
camera_config is None
or not camera_config.notifications.enabled
or name not in camera_config.semantic_search.triggers
or "notification"
not in camera_config.semantic_search.triggers[name].actions
):
return
if self.is_camera_suspended(camera):
logger.debug(f"Notifications for {camera} are currently suspended.")
return
self.send_trigger(decoded)
elif topic == "camera_monitoring":
decoded = json.loads(payload)
camera = decoded["camera"]
camera_config = self.config.cameras.get(camera)
if camera_config is None or not camera_config.notifications.enabled:
return
if self.is_camera_suspended(camera):
logger.debug(f"Notifications for {camera} are currently suspended.")
return
self.send_camera_monitoring(decoded)
elif topic == "notification_test":
if not self.config.notifications.enabled and not any(
cam.notifications.enabled for cam in self.config.cameras.values()
):
logger.debug(
"No cameras have notifications enabled, test notification not sent"
)
return
self.send_notification_test()
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
)
logger.debug(
f"Notification endpoint expired for {notification.user}, received {resp.status_code}"
)
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 _refresh_user_cameras(self) -> None:
"""Rebuild the user-to-cameras access cache from the database."""
all_camera_names = set(self.config.cameras.keys())
roles_dict = self.config.auth.roles
updated: dict[str, set[str]] = {}
for user in User.select(User.username, User.role).dicts().iterator():
allowed = User.get_allowed_cameras(
user["role"], roles_dict, all_camera_names
)
updated[user["username"]] = set(allowed)
logger.debug(
"User %s has access to cameras: %s",
user["username"],
", ".join(allowed),
)
self.user_cameras = updated
def _user_has_camera_access(self, username: str, camera: str) -> bool:
"""Check if a user has access to a specific camera based on cached roles."""
allowed = self.user_cameras.get(username)
if allowed is None:
logger.debug(f"No camera access information found for user {username}")
return False
return camera in allowed
def _within_cooldown(self, camera: str) -> bool:
now = datetime.datetime.now().timestamp()
if now - self.last_notification_time < self.config.notifications.cooldown:
logger.debug(
f"Skipping notification for {camera} - in global cooldown period"
)
return True
if (
now - self.last_camera_notification_time[camera]
< self.config.cameras[camera].notifications.cooldown
):
logger.debug(
f"Skipping notification for {camera} - in camera-specific cooldown period"
)
return True
return False
def send_notification_test(self) -> None:
if not self.config.notifications.email:
return
self.check_registrations()
logger.debug("Sending test notification")
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
camera: str = payload["after"]["camera"]
camera_name: str = getattr(
self.config.cameras[camera], "friendly_name", None
) or titlecase(camera.replace("_", " "))
current_time = datetime.datetime.now().timestamp()
if self._within_cooldown(camera):
return
self.check_registrations()
state = payload["type"]
# Don't notify if message is an update and important fields don't have an update
if (
state == "update"
and payload["before"]["severity"] == payload["after"]["severity"]
and len(payload["before"]["data"]["objects"])
== len(payload["after"]["data"]["objects"])
and len(payload["before"]["data"]["zones"])
== len(payload["after"]["data"]["zones"])
):
logger.debug(
f"Skipping notification for {camera} - message is an update and important fields don't have an update"
)
return
self.last_camera_notification_time[camera] = current_time
self.last_notification_time = current_time
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"])
image = f"{payload['after']['thumb_path'].replace(BASE_DIR, '')}"
ended = state == "end" or state == "genai"
if state == "genai" and payload["after"]["data"]["metadata"]:
base_title = payload["after"]["data"]["metadata"]["title"]
threat_level = payload["after"]["data"]["metadata"].get(
"potential_threat_level", 0
)
# Add prefix for threat levels 1 and 2
if threat_level == 1:
title = f"Needs Review: {base_title}"
elif threat_level == 2:
title = f"Security Concern: {base_title}"
else:
title = base_title
if payload["after"]["data"]["metadata"].get("shortSummary"):
message = payload["after"]["data"]["metadata"]["shortSummary"]
else:
message = f"Detected on {camera_name}"
else:
zone_names = payload["after"]["data"]["zones"]
formatted_zone_names = []
for zone_name in zone_names:
if zone_name in self.config.cameras[camera].zones:
formatted_zone_names.append(
self.config.cameras[camera]
.zones[zone_name]
.get_formatted_name(zone_name)
)
else:
formatted_zone_names.append(titlecase(zone_name.replace("_", " ")))
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {', '.join(formatted_zone_names)}"
message = f"Detected on {camera_name}"
if ended:
logger.debug(
f"Sending a notification with state {state} and message {message}"
)
# if event is ongoing open to live view otherwise open to recordings view
direct_url = f"/review?id={reviewId}" if ended else f"/#{camera}"
ttl = 3600 if ended else 0
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
for user in self.web_pushers:
if not self._user_has_camera_access(user, camera):
logger.debug(
"Skipping notification for user %s - no access to camera %s",
user,
camera,
)
continue
self.send_push_notification(
user=user,
payload=payload,
title=title,
message=message,
direct_url=direct_url,
image=image,
ttl=ttl,
)
self.cleanup_registrations()
def send_trigger(self, payload: dict[str, Any]) -> None:
if not self.config.notifications.email:
return
camera: str = payload["camera"]
camera_name: str = getattr(
self.config.cameras[camera], "friendly_name", None
) or titlecase(camera.replace("_", " "))
current_time = datetime.datetime.now().timestamp()
if self._within_cooldown(camera):
return
self.check_registrations()
self.last_camera_notification_time[camera] = current_time
self.last_notification_time = current_time
trigger_type = payload["type"]
event_id = payload["event_id"]
name = payload["name"]
score = payload["score"]
title = f"{name.replace('_', ' ')} triggered on {camera_name}"
message = f"{titlecase(trigger_type)} trigger fired for {camera_name} with score {score:.2f}"
image = f"clips/triggers/{camera}/{event_id}.webp"
direct_url = f"/explore?event_id={event_id}"
ttl = 0
logger.debug(
f"Sending push notification for {camera_name}, trigger name {name}"
)
for user in self.web_pushers:
if not self._user_has_camera_access(user, camera):
logger.debug(
"Skipping notification for user %s - no access to camera %s",
user,
camera,
)
continue
self.send_push_notification(
user=user,
payload=payload,
title=title,
message=message,
direct_url=direct_url,
image=image,
ttl=ttl,
)
self.cleanup_registrations()
def send_camera_monitoring(self, payload: dict[str, Any]) -> None:
camera: str = payload["camera"]
camera_name: str = getattr(
self.config.cameras[camera], "friendly_name", None
) or titlecase(camera.replace("_", " "))
self.check_registrations()
text: str = payload.get("message") or payload.get("reasoning", "")
title = f"{camera_name}: Monitoring Alert"
message = (text[:197] + "...") if len(text) > 200 else text
logger.debug(f"Sending camera monitoring push notification for {camera_name}")
for user in self.web_pushers:
if not self._user_has_camera_access(user, camera):
logger.debug(
"Skipping notification for user %s - no access to camera %s",
user,
camera,
)
continue
self.send_push_notification(
user=user,
payload=payload,
title=title,
message=message,
)
self.cleanup_registrations()
def stop(self) -> None:
logger.info("Closing notification queue")
self.notification_thread.join()