Coverage for custom_components/supernotify/transport.py: 99%

241 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-08 17:42 +0000

1from __future__ import annotations 

2 

3import datetime as dt 

4import logging 

5import re 

6import time 

7import unicodedata 

8from abc import abstractmethod 

9from traceback import format_exception 

10from typing import TYPE_CHECKING, Any, ClassVar 

11from urllib.parse import urlparse 

12 

13from homeassistant.components.notify.const import ATTR_TARGET 

14from homeassistant.const import ( 

15 ATTR_ENTITY_ID, 

16 ATTR_FRIENDLY_NAME, 

17 ATTR_NAME, 

18) 

19from homeassistant.exceptions import IntegrationError, ServiceNotFound 

20from homeassistant.util import dt as dt_util 

21 

22from custom_components.supernotify.model import ( 

23 DebugTrace, 

24 TargetRequired, 

25 TransportConfig, 

26 TransportFeature, 

27) 

28from custom_components.supernotify.schema import SelectionRank 

29from custom_components.supernotify.target import Target, TargetEntityCategory 

30 

31from . import DOMAIN 

32from .common import CallRecord 

33from .const import ( 

34 ATTR_ENABLED, 

35 ATTR_MEDIA_SNAPSHOT_URL, 

36 CONF_DELIVERY_DEFAULTS, 

37 INCLUSION_DEFAULT, 

38 INCLUSION_EXPLICIT, 

39) 

40from .model import DeliveryConfig, SuppressionReason 

41from .options import DeliveryOption 

42 

43 

44def default_target_required( 

45 unique: list[str | TargetEntityCategory], 

46 fallback: list[str | TargetEntityCategory], 

47 other: list[str | TargetEntityCategory], 

48) -> TargetRequired: 

49 """A transport that declares no target categories at all never has anything to resolve 

50 a target from, so it can't need one; a transport that declares any does, by default.""" 

51 return TargetRequired.NEVER if not (unique or fallback or other) else TargetRequired.ALWAYS 

52 

53 

54def default_selection_rank( 

55 unique: list[str | TargetEntityCategory], fallback: list[str | TargetEntityCategory] 

56) -> SelectionRank: 

57 """A transport that can definitively claim a unique target category should be tried before 

58 a fallback one; which is presumed to be either more broadly defined in terms of platforms 

59 supported, or not as capable as the unique handler. Designed for Notify Entity.""" 

60 if unique: 

61 return SelectionRank.FIRST 

62 if fallback: 

63 return SelectionRank.LAST 

64 return SelectionRank.ANY 

65 

66 

67def default_inclusion(unique: list[str | TargetEntityCategory], fallback: list[str | TargetEntityCategory]) -> list[str]: 

68 """A transport that can never definitively claim anything (no unique/fallback category) 

69 has no way to prove a given notification is relevant to it, so it requires explicit 

70 enablement by config / scenario / action to be considered for delivery""" 

71 return [INCLUSION_DEFAULT] if (unique or fallback) else [INCLUSION_EXPLICIT] 

72 

73 

74if TYPE_CHECKING: 

75 from anyio import Path 

76 from homeassistant.helpers.typing import ConfigType 

77 

78 from .context import Context 

79 from .delivery import Delivery, DeliveryRegistry 

80 from .envelope import Envelope 

81 from .hass_api import HomeAssistantAPI 

82 from .people import PeopleRegistry 

83 

84# Markup that spoken transports hand straight to the voice assistant. Simplification 

85# strips angle brackets, so SSML has to be passed through untouched or the assistant 

86# ends up speaking the tag names out loud. 

87RE_MARKUP_TAG = re.compile(r"(</?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?/?>)") 

88RE_MARKUP_TAG_NAME = re.compile(r"</?([A-Za-z][\w.:-]*)") 

89SSML_TAG_NAMES = frozenset({ 

90 "alexa:name", 

91 "amazon:domain", 

92 "amazon:effect", 

93 "amazon:emotion", 

94 "audio", 

95 "break", 

96 "emphasis", 

97 "lang", 

98 "mark", 

99 "phoneme", 

100 "prosody", 

101 "say-as", 

102 "speak", 

103 "sub", 

104 "voice", 

105}) 

106 

107# Emoji prefix per priority, for a transport that offers one (medium: no prefix) 

108PRIORITY_PREFIX: dict[str, str] = { 

109 "critical": "\U0001f6a8 ", # police car light 

110 "high": "⚠️ ", # warning sign 

111 "low": "\U0001f539 ", # small blue diamond 

112 "minimum": "\U0001f539 ", # small blue diamond 

113} 

114 

115# Sign characters kept even though their Unicode category (Sm) would otherwise be stripped, 

116# so numeric values like "+3" or "-3" aren't left indistinguishable from "3". 

117SIGN_CHARS = frozenset("+-=%") 

118 

119_LOGGER = logging.getLogger(__name__) 

120 

121 

122class Transport: 

123 """Base class for delivery transports. 

124 

125 Sub classes integrste with Home Assistant notification services 

126 or alternative notification mechanisms. 

127 """ 

128 

129 name: str 

130 # the Home Assistant integration this transport depends on, if any - only used to judge 

131 # how loudly to log when the transport isn't viable 

132 integration_domain: ClassVar[str | None] = None 

133 declared_options: ClassVar[list[DeliveryOption]] = [] 

134 # a transport sending only through an action the configuration names, with no way of finding one itself, 

135 # whose standard delivery is the transport's own when its `delivery_defaults` has an action 

136 action_configured: ClassVar[bool] = False 

137 # whether it makes its own network connection, set up by its `connection` configuration 

138 uses_connection: ClassVar[bool] = False 

139 

140 @abstractmethod 

141 def __init__(self, context: Context, transport_config: ConfigType | None = None) -> None: 

142 self.hass_api: HomeAssistantAPI = context.hass_api 

143 self.people_registry: PeopleRegistry = context.people_registry 

144 self.delivery_registry: DeliveryRegistry = context.delivery_registry 

145 self.context: Context = context 

146 transport_config = transport_config or {} 

147 self.transport_config = TransportConfig(transport_config, class_config=self.default_config) 

148 

149 self.delivery_defaults: DeliveryConfig = self.transport_config.delivery_defaults 

150 self.config_enabled = self.transport_config.enabled 

151 self.enabled = self.config_enabled 

152 self.alias = self.transport_config.alias 

153 self.last_error_at: dt.datetime | None = None 

154 self.last_error_in: str | None = None 

155 self.last_error_message: str | None = None 

156 self.error_count: int = 0 

157 self._unavailable: bool = False 

158 

159 async def initialize(self) -> None: 

160 """Async post-construction initialization""" 

161 if self.name is None: 

162 raise IntegrationError("Invalid nameless transport adaptor subclass") 

163 

164 def setup_delivery_options(self, options: dict[str, Any], delivery_name: str) -> dict[str, Any]: 

165 return {} 

166 

167 @property 

168 def supported_features(self) -> TransportFeature: 

169 return TransportFeature.MESSAGE | TransportFeature.TITLE 

170 

171 @property 

172 def targets(self) -> Target: 

173 return self.delivery_defaults.target if self.delivery_defaults.target is not None else Target() 

174 

175 @property 

176 def unique_target_categories(self) -> list[str | TargetEntityCategory]: 

177 """Target categories nothing else could claim - a value shape/prefix unique to this 

178 transport (e.g. `ATTR_EMAIL`), or an entity domain+platform combination unique to it. 

179 

180 This is what drives implicit selection (`Notification.select_deliveries()`): a 

181 delivery whose transport declares one of these, and whose category is actually 

182 present in the notification's target or an available recipient's own capabilities, 

183 becomes an auto-selection candidate - the same way giving an email address has always 

184 implied the `email` delivery. Empty by default; a transport with nothing here is 

185 never implicitly selected on its own, only by an explicit call/scenario/recipient ask. 

186 """ 

187 return [] 

188 

189 @property 

190 def fallback_target_categories(self) -> list[str | TargetEntityCategory]: 

191 """Target categories this transport will claim once nothing more specific did - 

192 e.g. `notify_entity`'s bare `notify.*` domain match, after html5/alexa_devices' own 

193 platform-scoped (and therefore `unique`) matches on the same domain get first refusal. 

194 

195 Also drives implicit selection, like `unique_target_categories`, but ranked to lose 

196 selection-order ties to a delivery selected via a `unique` match (see 

197 `default_selection_rank()`). 

198 """ 

199 return [] 

200 

201 @property 

202 def other_target_categories(self) -> list[str | TargetEntityCategory]: 

203 """Target categories this transport understands well enough to build a full envelope 

204 from, but that are too ambiguous to drive auto-selection on their own - several 

205 transports competing for the same bare entity domain (`media_player`, matched by 

206 kodi/tts/the `media` transport/alexa_media_player/chime), where an entity alone 

207 doesn't say which one of them was actually meant. 

208 """ 

209 return [] 

210 

211 @property 

212 def target_categories(self) -> list[str | TargetEntityCategory]: 

213 """The target categories this transport understands, independent of any delivery - 

214 the union of `unique_target_categories`, `fallback_target_categories` and 

215 `other_target_categories`, for envelope-building (`Delivery.target_categories`/ 

216 `Delivery.select_targets()`). Selection itself only ever looks at the first two. 

217 """ 

218 return [*self.unique_target_categories, *self.fallback_target_categories, *self.other_target_categories] 

219 

220 @property 

221 def default_config(self) -> TransportConfig: 

222 config = TransportConfig() 

223 config.delivery_defaults.target_required = default_target_required( 

224 self.unique_target_categories, self.fallback_target_categories, self.other_target_categories 

225 ) 

226 config.delivery_defaults.selection_rank = default_selection_rank( 

227 self.unique_target_categories, self.fallback_target_categories 

228 ) 

229 config.delivery_defaults.inclusion = default_inclusion(self.unique_target_categories, self.fallback_target_categories) 

230 return config 

231 

232 def is_viable(self, hass_api: HomeAssistantAPI) -> bool: 

233 """Whether this transport currently has what it needs to auto-configure a delivery. 

234 

235 Always True for an `action_configured` transport: an explicit delivery can supply its own action 

236 regardless of whether the service is discoverable here, and is_viable() can't see delivery-level 

237 config, so it can't rule that out - DeliveryRegistry prunes the transport entirely once it's 

238 confirmed no delivery (explicit or auto) uses it. 

239 

240 Default implementation just defers to `build_standard_deliveries()` and checks for 

241 a non-empty result - correct for any transport, but builds (and discards) the 

242 `DeliveryConfig`s to answer what's otherwise a yes/no question. Override with a 

243 standalone check (matching `build_standard_deliveries()`'s own condition) in a 

244 transport where that's cheap and doesn't require mutating `self.delivery_defaults` 

245 to find out - most transports that gate purely on hass_api state (a config entry, a 

246 registered service, discovered entities) can. Skip the override where viability can 

247 only be discovered by doing the same service/entity lookup 

248 `build_standard_deliveries()` itself needs to build the config (e.g. `discord`, 

249 `pushover`, `sms` - discovering *which* service is available - or `email`, which 

250 also decides *how* to send based on what's found). 

251 """ 

252 return self.action_configured or bool(self.build_standard_deliveries(hass_api)) 

253 

254 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]: 

255 """Build every 'standard' (auto-generatable) delivery this transport contributes, 

256 keyed by name: its own default (keyed by `self.name`) plus any extras. 

257 

258 Only ever called once `is_viable()` has returned True for the same `hass_api` - 

259 callers must check that first. Most overrides trust this and skip re-checking 

260 their own viability condition; the exception is a transport whose viability can 

261 only be discovered by doing the very lookup this method needs anyway (see 

262 `is_viable()`'s docstring) - those keep their own guard and still return an empty 

263 dict, simply because there's nothing to gain by trusting the caller there. 

264 """ 

265 if self.action_configured and self.delivery_defaults.action: 

266 return {self.name: {}} 

267 return {} 

268 

269 def validate_action(self, action: str | None) -> bool: 

270 """Override in subclass if transport has fixed action or doesn't require one""" 

271 return action == self.delivery_defaults.action 

272 

273 def priority_prefix(self, envelope: Envelope) -> str: 

274 """Emoji to start a message with for its priority, or nothing for medium""" 

275 return PRIORITY_PREFIX.get(envelope.priority or "medium", "") 

276 

277 def validate_notify_action(self, action: str | None) -> bool: 

278 """For a transport sending through an integration's `notify.<name>` action, named as the user chose""" 

279 if action and action.startswith("notify.") and action.split(".", 1)[1]: 

280 return True 

281 _LOGGER.warning("SUPERNOTIFY %s: action must be a notify.* service, got: %r", self.name, action) 

282 return False 

283 

284 async def safe_grab_image(self, envelope: Envelope) -> Path | None: 

285 """The notification's image, or None, a failure to get one not stopping the delivery""" 

286 try: 

287 return await envelope.grab_image() 

288 except Exception as e: 

289 _LOGGER.warning("SUPERNOTIFY %s: failed to grab image: %s", self.name, e) 

290 return None 

291 

292 def absolute_url(self, url: str) -> str | None: 

293 """A URL the recipient's device can fetch, for one that may be relative to Home Assistant""" 

294 return self.hass_api.abs_url(url) 

295 

296 async def image_url(self, envelope: Envelope, grab: bool = True) -> str | None: 

297 """A URL the recipient's device can fetch the notification's image from - its `snapshot_url`, 

298 or else, with `grab`, an image grabbed from its camera and served from the media folder""" 

299 snapshot_url: str | None = (envelope.media or {}).get(ATTR_MEDIA_SNAPSHOT_URL) 

300 if snapshot_url: 

301 return self.absolute_url(str(snapshot_url)) 

302 if not grab: 

303 return None 

304 image_path: Path | None = await self.safe_grab_image(envelope) 

305 if image_path is None: 

306 return None 

307 try: 

308 return await self.context.media_storage.object_url(image_path) 

309 except Exception as e: 

310 _LOGGER.debug("SUPERNOTIFY %s: no url for %s: %s", self.name, image_path, e) 

311 return None 

312 

313 def attributes(self) -> dict[str, Any]: 

314 attrs: dict[str, Any] = { 

315 ATTR_NAME: self.name, 

316 ATTR_ENABLED: self.enabled, 

317 CONF_DELIVERY_DEFAULTS: self.delivery_defaults, 

318 } 

319 if self.alias: 

320 attrs[ATTR_FRIENDLY_NAME] = self.alias 

321 if self.last_error_at: 

322 attrs["last_error_at"] = self.last_error_at 

323 attrs["last_error_in"] = self.last_error_in 

324 attrs["last_error_message"] = self.last_error_message 

325 attrs["error_count"] = self.error_count 

326 attrs.update(self.extra_attributes()) 

327 return attrs 

328 

329 def extra_attributes(self) -> dict[str, Any]: 

330 return {} 

331 

332 @abstractmethod 

333 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: 

334 """Delivery implementation 

335 

336 Args: 

337 ---- 

338 envelope (Envelope): envelope to be delivered 

339 debug_trace (DebugTrace): debug info collector 

340 

341 """ 

342 

343 async def common_string(self, key: str) -> str: 

344 """Text for the recipient from the `common` translations, in the Home Assistant language""" 

345 strings: dict[str, str] = await self.hass_api.translations("common") 

346 return strings[f"component.{DOMAIN}.common.{key}"] 

347 

348 def set_action_data(self, action_data: dict[str, Any], key: str, data: Any | None) -> dict[str, Any]: # ruff: ignore[any-type] 

349 if data is not None: 

350 action_data[key] = data 

351 return action_data 

352 

353 async def call_action( 

354 self, 

355 envelope: Envelope, 

356 qualified_action: str | None = None, 

357 action_data: dict[str, Any] | None = None, 

358 target_data: dict[str, Any] | None = None, 

359 implied_target: bool = False, # True if the qualified action implies a target 

360 single_target: bool = False, # True if the action reaches only one target, so not finding it isn't an error 

361 ) -> bool: 

362 action_data = action_data or {} 

363 start_time = time.time() 

364 domain = service = None 

365 delivery: Delivery = envelope.delivery 

366 try: 

367 qualified_action = qualified_action or delivery.action 

368 if not qualified_action: 

369 _LOGGER.debug( 

370 "SUPERNOTIFY Skipping %s action call with no service, targets %s", 

371 envelope.delivery.name, 

372 action_data.get(ATTR_TARGET), 

373 ) 

374 envelope.skipped = 1 

375 envelope.skip_reason = SuppressionReason.NO_ACTION 

376 return False 

377 if ( 

378 delivery.target_required == TargetRequired.ALWAYS 

379 and not action_data.get(ATTR_TARGET) 

380 and not action_data.get(ATTR_ENTITY_ID) 

381 and not implied_target 

382 and not target_data 

383 ): 

384 _LOGGER.debug( 

385 "SUPERNOTIFY Skipping %s action call for service %s, missing targets", 

386 envelope.delivery.name, 

387 qualified_action, 

388 ) 

389 envelope.skipped = 1 

390 envelope.skip_reason = SuppressionReason.NO_TARGET 

391 return False 

392 

393 domain, service = qualified_action.split(".", 1) 

394 # a delivery with a fallback, or used as one, waits for its call, so that an error in the 

395 # service is seen and the next one can be tried - otherwise only a missing service or bad 

396 # data would be 

397 wait: dict[str, Any] = {"blocking": True} if (delivery.fallback or delivery.is_fallback) else {} 

398 start_time = time.time() 

399 timestamp: dt.datetime | None = None 

400 if target_data: 

401 # home-assistant messes with the service_data passed by ref 

402 service_data_as_sent = dict(action_data) 

403 timestamp = dt.datetime.now(tz=dt_util.get_default_time_zone()) 

404 service_response = await self.hass_api.call_service( 

405 domain, 

406 service, 

407 service_data=action_data, 

408 target=target_data, 

409 debug=delivery.debug, 

410 context=envelope.ha_context, 

411 **wait, 

412 ) 

413 envelope.calls.append( 

414 CallRecord( 

415 timestamp, 

416 time.time() - start_time, 

417 domain, 

418 service, 

419 debug=delivery.debug, 

420 action_data=service_data_as_sent, 

421 target_data=target_data, 

422 service_response=service_response, 

423 ) 

424 ) 

425 else: 

426 service_data_as_sent = dict(action_data) 

427 timestamp = dt.datetime.now(tz=dt_util.get_default_time_zone()) 

428 service_response = await self.hass_api.call_service( 

429 domain, service, service_data=action_data, debug=delivery.debug, context=envelope.ha_context, **wait 

430 ) 

431 envelope.calls.append( 

432 CallRecord( 

433 timestamp, 

434 time.time() - start_time, 

435 domain, 

436 service, 

437 debug=delivery.debug, 

438 action_data=service_data_as_sent, 

439 service_response=service_response, 

440 ) 

441 ) 

442 

443 envelope.delivered = 1 

444 self.log_delivery_recovered() 

445 return True 

446 except Exception as e: 

447 not_found: bool = isinstance(e, ServiceNotFound) 

448 envelope.failed_calls.append( 

449 CallRecord( 

450 timestamp, 

451 time.time() - start_time, 

452 domain, 

453 service, 

454 action_data, 

455 target_data, 

456 exception=str(e), 

457 ) 

458 ) 

459 if not_found and single_target: 

460 # like a rejected e-mail address, one target that couldn't be reached rather than 

461 # the transport failing 

462 _LOGGER.warning("SUPERNOTIFY Missed %s target, %s, id: %s", self.name, e, envelope.notification_id) 

463 return False 

464 self.record_error(str(e), method="call_action") 

465 self.log_delivery_failure( 

466 e, "SUPERNOTIFY Failed to notify %s via %s.%s, id: %s", self.name, domain, service, envelope.notification_id 

467 ) 

468 envelope.error_count += 1 

469 # a missing action explains itself, only an unexpected error needs its stack trace 

470 envelope.delivery_error = [str(e)] if not_found else format_exception(e) 

471 return False 

472 

473 def record_error(self, message: str, method: str) -> None: 

474 self.last_error_at = dt_util.utcnow() 

475 self.last_error_message = message 

476 self.last_error_in = method 

477 self.error_count += 1 

478 

479 def log_delivery_failure(self, err: BaseException, message: str, *args: Any) -> None: 

480 """Log a delivery failure, passing the exception caught in the caller's except block. 

481 

482 Logged at ERROR (with traceback, unless it's only a missing action) the first time this transport becomes unavailable, 

483 then downgraded to DEBUG for consecutive failures until it recovers - avoids 

484 spamming the log every notification while an external service/device stays down. 

485 Call alongside record_error(), which keeps tracking the lifetime error count 

486 regardless of log level. 

487 """ 

488 exc_info: BaseException | None = None if isinstance(err, ServiceNotFound) else err 

489 if self._unavailable: 

490 _LOGGER.debug(message, *args, exc_info=exc_info) 

491 else: 

492 _LOGGER.error(message, *args, exc_info=exc_info) 

493 self._unavailable = True 

494 

495 def log_delivery_recovered(self) -> None: 

496 """Call on a successful delivery - logs once if this transport was previously 

497 flagged unavailable, then clears the flag.""" 

498 if self._unavailable: 

499 _LOGGER.info("SUPERNOTIFY %s transport recovered after prior delivery failures", self.name) 

500 self._unavailable = False 

501 

502 def simplify(self, text: str | None, strip_urls: bool = False) -> str | None: 

503 """Simplify text for delivery transports with speaking or plain text interfaces. 

504 

505 Spoken transports can be handed SSML, which the voice assistant parses itself. 

506 Simplification removes angle brackets, so applying it to SSML turns the markup 

507 into words the assistant reads out loud. When a spoken transport is given SSML, 

508 the tags are left alone and only the text around them is simplified, so emoji, 

509 URLs and symbols are still cleaned up. 

510 """ 

511 if not text: 

512 return None 

513 if self.supported_features & TransportFeature.SPOKEN and self._is_ssml(text): 

514 simplified = "".join( 

515 fragment if index % 2 else self._simplify_around_markup(fragment, strip_urls) 

516 for index, fragment in enumerate(RE_MARKUP_TAG.split(text)) 

517 ) 

518 else: 

519 simplified = self._simplify_text(text, strip_urls) 

520 _LOGGER.debug("SUPERNOTIFY Simplified text to: %s", simplified) 

521 return simplified 

522 

523 @staticmethod 

524 def _is_ssml(text: str) -> bool: 

525 """Tell SSML markup apart from stray angle brackets in ordinary text.""" 

526 for tag in RE_MARKUP_TAG.findall(text): 

527 name = RE_MARKUP_TAG_NAME.match(tag) 

528 if name is not None and name.group(1).lower() in SSML_TAG_NAMES: 

529 return True 

530 return False 

531 

532 @staticmethod 

533 def _strip_urls(text: str) -> str: 

534 return " ".join(word for word in text.split() if not (urlparse(word).scheme and urlparse(word).netloc)) 

535 

536 @classmethod 

537 def _simplify_text(cls, text: str, strip_urls: bool = False) -> str: 

538 """Remove symbols, and optionally URLs, that can trip up voice assistants.""" 

539 if strip_urls: 

540 text = cls._strip_urls(text) 

541 text = unicodedata.normalize("NFC", text) 

542 text = text.translate(str.maketrans("_", " ", "()£$<>")) 

543 return "".join(c for c in text if c in SIGN_CHARS or unicodedata.category(c) not in ("So", "Sk", "Sm", "Mn", "Sc")) 

544 

545 @classmethod 

546 def _simplify_around_markup(cls, fragment: str, strip_urls: bool) -> str: 

547 """Simplify a fragment of text sitting between two SSML tags, keeping the 

548 whitespace at either end so that words do not end up glued to the markup.""" 

549 if not fragment.strip(): 

550 return fragment 

551 lead = fragment[: len(fragment) - len(fragment.lstrip())] 

552 trail = fragment[len(fragment.rstrip()) :] 

553 return f"{lead}{cls._simplify_text(fragment.strip(), strip_urls)}{trail}"