Coverage for custom_components/camera_autonotify/notifier.py: 100%
74 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:32 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:32 +0000
1"""The notification for each decision of the tracker, through Supernotify, or a plain one without it"""
3from __future__ import annotations
5import logging
6from typing import TYPE_CHECKING, Any
8from homeassistant.const import ATTR_ENTITY_ID, CONF_TYPE
9from homeassistant.exceptions import HomeAssistantError
10from homeassistant.helpers import entity_registry as er
12from .const import (
13 ACTION_VIEW_SNAPSHOT,
14 CONF_CUSTOM_TARGET,
15 CONF_DELIVERIES,
16 CONF_SOURCE_ENTRY_ID,
17 CONF_TARGET,
18 CONF_TARGETS,
19 MESSAGE_ENDED,
20 MESSAGE_IN_ZONES,
21 MESSAGE_ON_CAMERA,
22 MESSAGE_SEEN,
23 SUPERNOTIFY_DOMAIN,
24 TYPE_ZONE,
25)
26from .frigate import base_url, camera_entity_id, hls_url, snapshot_url
27from .supernotify_api import supernotify_available
29if TYPE_CHECKING:
30 from homeassistant.config_entries import ConfigEntry
31 from homeassistant.core import Context, HomeAssistant
33 from .tracker import Decision
35_LOGGER = logging.getLogger(__name__)
37MOBILE_APP_PLATFORM = "mobile_app"
38# by severity, an alert to be noticed and a detection not to interrupt, both left to Supernotify to map to each
39# platform, as iOS's time sensitive or Android's high importance
40PRIORITY_ALERT = "high"
41PRIORITY_DETECTION = "medium"
42# an update with alert once, no sound or interruption, iOS's passive
43PRIORITY_SILENT = "low"
44SEVERITY_ALERT = "alert"
47def _friendly(name: str) -> str:
48 """A zone or camera as Frigate writes it, `Barn_Door`, as said in a notification"""
49 return name.replace("_", " ").title()
52class Notifier:
53 """Sends each notification for a camera's or a zone's entry"""
55 def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
56 self.hass = hass
57 self.entry = entry
58 self.name: str = entry.title
59 self.is_zone: bool = entry.data.get(CONF_TYPE) == TYPE_ZONE
60 self.source_entry_id: str = entry.data[CONF_SOURCE_ENTRY_ID]
62 def message(self, decision: Decision) -> str:
63 review = decision.review
64 if self.is_zone:
65 message = MESSAGE_ON_CAMERA.format(label=review.label, camera=_friendly(review.camera))
66 elif review.zones:
67 message = MESSAGE_IN_ZONES.format(label=review.label, zones=", ".join(_friendly(z) for z in review.zones))
68 else:
69 message = MESSAGE_SEEN.format(label=review.label)
70 return MESSAGE_ENDED.format(message=message) if decision.is_final else message
72 def supernotify_data(self, decision: Decision) -> dict[str, Any]:
73 """The `supernotify.notify` call: the snapshot, tapped for the clip as HLS, and never `clip_url`, which iOS
74 fails to show, even the image"""
75 review = decision.review
76 if decision.silent:
77 priority = PRIORITY_SILENT
78 else:
79 priority = PRIORITY_ALERT if review.severity == SEVERITY_ALERT else PRIORITY_DETECTION
80 data: dict[str, Any] = {
81 "title": self.name,
82 "message": self.message(decision),
83 "priority": priority,
84 "extra_data": {
85 # each update of a review takes the place of its last notification
86 "mobile_push_notification_tag": f"frigate_{review.id}",
87 "group": f"frigate_{review.camera}",
88 },
89 }
90 media: dict[str, Any] = {}
91 if camera := camera_entity_id(self.hass, self.source_entry_id, review.camera):
92 # for Supernotify's camera snooze, the snapshot still being the image
93 media["camera_entity_id"] = camera
94 base = base_url(self.hass)
95 if base and decision.detection_id:
96 snapshot = snapshot_url(base, decision.detection_id)
97 media["snapshot_url"] = snapshot
98 data["actions"] = [
99 {"level": "notification", "uri": hls_url(base, decision.detection_id, review.camera)},
100 {"action": "URI", "title": ACTION_VIEW_SNAPSHOT, "uri": snapshot},
101 ]
102 if media:
103 data["media"] = media
104 if deliveries := self.entry.options.get(CONF_DELIVERIES):
105 data["delivery"] = list(deliveries)
106 data["delivery_selection"] = "explicit"
107 for key in (CONF_TARGET, CONF_CUSTOM_TARGET):
108 if value := self.entry.options.get(key):
109 data[key] = value
110 return data
112 async def async_notify(self, decision: Decision, context: Context) -> None:
113 if not supernotify_available(self.hass):
114 await self._send_message(decision, context)
115 return
116 try:
117 await self.hass.services.async_call(
118 SUPERNOTIFY_DOMAIN, "notify", self.supernotify_data(decision), blocking=True, context=context
119 )
120 except HomeAssistantError:
121 _LOGGER.exception("CAMERA_AUTONOTIFY Supernotify notification failed for %s", self.name)
123 async def _send_message(self, decision: Decision, context: Context) -> None:
124 """Plain text only, with no snapshot or link, Supernotify being what's needed for those"""
125 if decision.is_update:
126 # each would be a new notification, with nothing to take the place of the last
127 return
128 # with none chosen, every phone with the mobile app is told
129 targets: list[str] = self.entry.options.get(CONF_TARGETS) or [
130 entity.entity_id
131 for entity in er.async_get(self.hass).entities.values()
132 if entity.domain == "notify" and entity.platform == MOBILE_APP_PLATFORM and not entity.disabled
133 ]
134 if not targets:
135 _LOGGER.warning("CAMERA_AUTONOTIFY Nothing to notify for %s, choose targets in its settings", self.name)
136 return
137 try:
138 await self.hass.services.async_call(
139 "notify",
140 "send_message",
141 {ATTR_ENTITY_ID: targets, "title": self.name, "message": self.message(decision)},
142 blocking=True,
143 context=context,
144 )
145 except HomeAssistantError:
146 _LOGGER.exception("CAMERA_AUTONOTIFY Notification failed for %s", self.name)