Coverage for custom_components / supernotify / envelope.py: 89%

194 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-11 22:18 +0000

1from __future__ import annotations 

2 

3import copy 

4import logging 

5import string 

6import time 

7import typing 

8import uuid 

9from typing import Any, cast 

10 

11from homeassistant.components.notify.const import ATTR_MESSAGE, ATTR_TITLE 

12from homeassistant.helpers.template import is_template_string 

13from jinja2 import TemplateError 

14 

15from .common import DupeCheckable 

16from .const import ( 

17 ATTR_MEDIA, 

18 ATTR_MESSAGE_HTML, 

19 ATTR_PRIORITY, 

20 ATTR_SPOKEN_MESSAGE, 

21 ATTR_TIMESTAMP, 

22 OPTION_DATA_KEYS_SELECT, 

23 OPTION_MESSAGE_USAGE, 

24 OPTION_SIMPLIFY_TEXT, 

25 OPTION_STRIP_URLS, 

26 PRIORITY_MEDIUM, 

27) 

28from .media_grab import grab_image 

29from .model import ( 

30 ConditionVariables, 

31 DataFilter, 

32 DeliveryCustomization, 

33 MessageOnlyPolicy, 

34 SuppressionReason, 

35 Target, 

36 TargetRequired, 

37 TransportFeature, 

38) 

39 

40if typing.TYPE_CHECKING: 

41 from anyio import Path 

42 

43 from custom_components.supernotify.common import CallRecord 

44 

45 from .context import Context 

46 from .delivery import Delivery 

47 from .notification import Notification 

48 from .scenario import Scenario 

49 

50_LOGGER = logging.getLogger(__name__) 

51 

52HASH_PREP_TRANSLATION_TABLE = table = str.maketrans("", "", string.punctuation + string.digits) 

53 

54 

55class Envelope(DupeCheckable): 

56 """Wrap a notification with a specific set of targets and service data possibly customized for those targets""" 

57 

58 def __init__( 

59 self, 

60 delivery: Delivery, 

61 notification: Notification | None = None, 

62 target: Target | None = None, # targets only for this delivery 

63 data: dict[str, Any] | None = None, 

64 context: Context | None = None, # notification data customized for this delivery 

65 ) -> None: 

66 self.target: Target = target or Target() 

67 self.context: Context | None = context 

68 self.delivery_name: str = delivery.name 

69 self.delivery: Delivery = delivery 

70 self._notification = notification 

71 self.notification_id = None 

72 self.media = None 

73 self.action_groups = None 

74 self.priority = PRIORITY_MEDIUM 

75 self._message: str | None = None 

76 self._title: str | None = None 

77 self.message_html: str | None = None 

78 self.data: dict[str, Any] = {} 

79 self.actions: list[dict[str, Any]] = [] 

80 if notification: 

81 delivery_config_data: dict[str, Any] = notification.delivery_data(delivery) 

82 self._enabled_scenarios: dict[str, Scenario] = notification.enabled_scenarios 

83 self._message = delivery_config_data.pop(ATTR_MESSAGE, notification.message) 

84 self._title = delivery_config_data.pop(ATTR_TITLE, notification._title) 

85 self.id = f"{notification.id}_{self.delivery_name}" 

86 else: 

87 delivery_config_data = {} 

88 self._enabled_scenarios = {} 

89 self.id = str(uuid.uuid1()) 

90 if data: 

91 self.data = copy.deepcopy(data) 

92 if delivery_config_data: 

93 # notification-level delivery override wins over scenario/delivery data 

94 self.data |= delivery_config_data 

95 else: 

96 self.data = delivery_config_data if delivery_config_data else {} 

97 

98 if notification: 

99 self.notification_id = notification.id 

100 self.media = notification.media 

101 self.action_groups = notification.action_groups 

102 self.actions = notification.actions 

103 self.priority = self.data.get(ATTR_PRIORITY, notification.priority) 

104 self.message_html = self.data.get(ATTR_MESSAGE_HTML, notification.message_html) 

105 if notification and hasattr(notification, "condition_variables"): # yeuchh 

106 self.condition_variables = notification.condition_variables 

107 else: 

108 self.condition_variables = ConditionVariables() 

109 

110 self.message = self._compute_message() 

111 self.title = self._compute_title() 

112 

113 self.delivered: int = 0 

114 self.error_count: int = 0 

115 self.skipped: int = 0 

116 self.skip_reason: SuppressionReason | None = None 

117 self.calls: list[CallRecord] = [] 

118 self.failed_calls: list[CallRecord] = [] 

119 self.delivery_error: list[str] | None = None 

120 

121 def customize_data(self, data: dict[str, Any], prune_empty: bool = True) -> dict[str, Any]: 

122 """Return data filtered by delivery data_keys_select option, pruning empty maps by default.""" 

123 if not data: 

124 return data 

125 rules = self.delivery.options.get(OPTION_DATA_KEYS_SELECT) 

126 return DataFilter(rules).apply(data, prune_empty=prune_empty) 

127 

128 async def grab_image(self) -> Path | None: 

129 """Grab an image from a camera, snapshot URL, MQTT Image etc""" 

130 image_path: Path | None = None 

131 if self._notification: 

132 image_path = await grab_image(self._notification, self.delivery, self._notification.context) 

133 return image_path 

134 

135 def core_action_data(self, force_message: bool = True) -> dict[str, Any]: 

136 """Build the core set of `service_data` dict to pass to underlying notify service""" 

137 # TODO: remove all logic, so only called to pre-populate `data` 

138 data: dict[str, Any] = {} 

139 # message is mandatory for notify platform 

140 if self.message is None: 

141 if force_message: 

142 data[ATTR_MESSAGE] = "" 

143 else: 

144 data[ATTR_MESSAGE] = self.message 

145 timestamp = self.data.get(ATTR_TIMESTAMP) 

146 if timestamp and ATTR_MESSAGE in data: 

147 data[ATTR_MESSAGE] = f"{data[ATTR_MESSAGE]} [{time.strftime(timestamp, time.localtime())}]" 

148 if self.title is not None: 

149 data[ATTR_TITLE] = self.title 

150 return data 

151 

152 def contents(self, minimal: bool = True, **_kwargs: Any) -> dict[str, typing.Any]: 

153 exclude_attrs: list[str] = ["_notification", "context", "condition_variables"] 

154 if minimal: 

155 exclude_attrs.append("delivery") 

156 features: TransportFeature = self.delivery.transport.supported_features 

157 if not features & TransportFeature.ACTIONS: 

158 exclude_attrs.extend(["actions", "action_groups"]) 

159 if not features & TransportFeature.IMAGES and not features & TransportFeature.VIDEO: 

160 exclude_attrs.append(ATTR_MEDIA) 

161 if not features & TransportFeature.MESSAGE: 

162 exclude_attrs.extend(["message_html", "message"]) 

163 if not features & TransportFeature.TITLE: 

164 exclude_attrs.append("title") 

165 if self.delivery.target_required == TargetRequired.NEVER: 

166 exclude_attrs.append("target") 

167 

168 json_ready = {k: v for k, v in self.__dict__.items() if k not in exclude_attrs and not k.startswith("_")} 

169 json_ready["data"] = self._resolve_data_templates(self.data) 

170 json_ready["calls"] = [call.contents() for call in self.calls] 

171 json_ready["failedcalls"] = [call.contents() for call in self.failed_calls] 

172 return json_ready 

173 

174 def __eq__(self, other: Any | None) -> bool: 

175 """Specialized equality check for subset of attributes""" 

176 if other is None or not isinstance(other, Envelope): 

177 return False 

178 return bool( 

179 self.target == other.target 

180 and self.delivery_name == other.delivery_name 

181 and self.data == other.data 

182 and self.notification_id == other.notification_id 

183 ) 

184 

185 def __repr__(self) -> str: 

186 """Return a concise string representation of the Envelope. 

187 

188 The returned string includes the envelope's message, title, and delivery name 

189 in the form: Envelope(message=<message>,title=<title>,delivery=<delivery_name>). 

190 

191 Primarily intended for debugging and logging; note that attribute values are 

192 inserted directly and may not be quoted or escaped. 

193 """ 

194 return f"Envelope(message={self.message},title={self.title},delivery={self.delivery_name})" 

195 

196 def _compute_title(self, ignore_usage: bool = False) -> str | None: 

197 # message and title reverse the usual defaulting, delivery config overrides runtime call 

198 

199 title: str | None = None 

200 message_usage = self.delivery.option_str(OPTION_MESSAGE_USAGE) 

201 if not ignore_usage and message_usage.upper() in (MessageOnlyPolicy.USE_TITLE, MessageOnlyPolicy.COMBINE_TITLE): 

202 title = None 

203 else: 

204 title = self.delivery.title if self.delivery.title is not None else self._title 

205 if self.delivery.option_bool(OPTION_SIMPLIFY_TEXT) is True or self.delivery.option_bool(OPTION_STRIP_URLS) is True: 

206 title = self.delivery.transport.simplify(title, strip_urls=self.delivery.option_bool(OPTION_STRIP_URLS)) 

207 title = self._render_scenario_templates(title, "title_template", "notification_title") 

208 if title is None: 

209 return None 

210 return str(title) 

211 

212 def _spoken_message(self) -> str | None: 

213 """Alternative message only for spoken voice transports""" 

214 if ( 

215 self._notification 

216 and self._notification.extra_data 

217 and ATTR_SPOKEN_MESSAGE in self._notification.extra_data 

218 and self.delivery.transport.supported_features & TransportFeature.SPOKEN 

219 ): 

220 return str(self._notification.extra_data[ATTR_SPOKEN_MESSAGE]) 

221 return None 

222 

223 def _compute_message(self) -> str | None: 

224 # message and title reverse the usual defaulting, delivery config overrides runtime call 

225 

226 # self._message could be top level `message` or `message` set in delivery override 

227 msg: str | None = self.delivery.message if self.delivery.message is not None else self._message 

228 msg = self._spoken_message() or msg 

229 

230 if msg and self.context and is_template_string(msg): 

231 try: 

232 context_vars = cast("dict[str,Any]", self.condition_variables.as_dict()) if self.condition_variables else {} 

233 template = self.context.hass_api.template(msg) 

234 msg = template.async_render(variables=context_vars) 

235 except Exception as e: 

236 _LOGGER.warning("SUPERNOTIFY Rendering delivery message template for %s failed: %s", self.delivery_name, e) 

237 

238 message_usage: str = str(self.delivery.option_str(OPTION_MESSAGE_USAGE)) 

239 if message_usage.upper() == MessageOnlyPolicy.USE_TITLE: 

240 title = self._compute_title(ignore_usage=True) 

241 if title: 

242 msg = title 

243 elif message_usage.upper() == MessageOnlyPolicy.COMBINE_TITLE: 

244 title = self._compute_title(ignore_usage=True) 

245 if title: 

246 msg = f"{title} {msg}" 

247 

248 if self.delivery.option_bool(OPTION_SIMPLIFY_TEXT) is True or self.delivery.option_bool(OPTION_STRIP_URLS) is True: 

249 msg = self.delivery.transport.simplify(msg, strip_urls=self.delivery.option_bool(OPTION_STRIP_URLS)) 

250 

251 msg = self._render_scenario_templates(msg, "message_template", "notification_message") 

252 if msg is None: # keep mypy happy 

253 return None 

254 return str(msg) 

255 

256 def _render_scenario_templates(self, original: str | None, template_field: str, matching_ctx: str) -> str | None: 

257 """Apply templating to a field, like message or title""" 

258 rendered = original if original is not None else "" 

259 delivery_configs: list[DeliveryCustomization] = list( 

260 filter(None, (scenario.delivery_config(self.delivery.name) for scenario in self._enabled_scenarios.values())) 

261 ) 

262 template_formats: list[str] = [ 

263 dc.data_value(template_field) 

264 for dc in delivery_configs 

265 if dc is not None and dc.data_value(template_field) is not None 

266 ] 

267 if template_formats and self.context: 

268 if self.condition_variables: 

269 context_vars: dict[str, Any] = cast("dict[str,Any]", self.condition_variables.as_dict()) 

270 else: 

271 context_vars = {} 

272 for template_format in template_formats: 

273 context_vars[matching_ctx] = rendered 

274 try: 

275 template = self.context.hass_api.template(template_format) 

276 rendered = template.async_render(variables=context_vars) 

277 except TemplateError as e: 

278 self.error_count += 1 

279 _LOGGER.warning( 

280 "SUPERNOTIFY Rendering template %s for %s failed: %s", template_field, self.delivery.name, e 

281 ) 

282 return rendered 

283 return original 

284 

285 # DupeCheckable implementation 

286 

287 def hash(self) -> int: 

288 """Alpha hash to reduce noise from messages with timestamps or incrementing counts""" 

289 

290 def alphaize(v: str | None) -> str | None: 

291 return v.translate(HASH_PREP_TRANSLATION_TABLE) if v else v 

292 

293 message: str | None = self._spoken_message() or self._message 

294 return hash((alphaize(message), alphaize(self.delivery.name), self.target.hash_resolved(), alphaize(self._title))) 

295 

296 def _resolve_data_templates(self, data: dict[str, Any]) -> dict[str, Any]: 

297 """Resolve Jinja2 templates in data dict for archive readability. 

298 

299 Returns a copy of data with template strings replaced by their 

300 resolved values. Raw template string is preserved alongside as 

301 <key>_template for debugging. Non-template values are unchanged. 

302 """ 

303 if not data or not self.context: 

304 return data 

305 resolved: dict[str, Any] = {} 

306 context_vars = cast("dict[str, Any]", self.condition_variables.as_dict()) if self.condition_variables else {} 

307 for key, value in data.items(): 

308 if isinstance(value, str) and "{{" in value: 

309 try: 

310 rendered = self.context.hass_api.template(value).async_render(variables=context_vars) 

311 resolved[key] = rendered 

312 resolved[f"{key}_template"] = value 

313 except Exception as e: 

314 _LOGGER.debug("SUPERNOTIFY Could not resolve template for %s in %s: %s", key, self.delivery_name, e) 

315 resolved[key] = value 

316 else: 

317 resolved[key] = value 

318 return resolved