Coverage for custom_components/supernotify/notification.py: 96%

881 statements  

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

1from __future__ import annotations 

2 

3import asyncio 

4import datetime as dt 

5import logging 

6import uuid 

7from dataclasses import dataclass 

8from traceback import format_exception 

9from typing import TYPE_CHECKING, Any, cast 

10 

11import homeassistant.util.dt as dt_util 

12from homeassistant.components.notify.const import ATTR_DATA 

13from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_EXCLUDE 

14from homeassistant.helpers.trace import trace_id_get 

15 

16from custom_components.supernotify.delivery import DeliveryProvenance 

17from custom_components.supernotify.schema import SelectionRank 

18 

19from .archive import ArchivableObject 

20from .common import canonical_priority, ensure_list, nullable_ensure_list, sanitize, spoken_name 

21from .compat import humanize, vol 

22from .const import ( 

23 ATTR_ACTION_GROUPS, 

24 ATTR_ACTIONS, 

25 ATTR_DEBUG, 

26 ATTR_DELIVERY, 

27 ATTR_DELIVERY_SELECTION, 

28 ATTR_DRY_RUN, 

29 ATTR_FORCE_RESEND, 

30 ATTR_IMAGE, 

31 ATTR_MEDIA, 

32 ATTR_MEDIA_CAMERA_ENTITY_ID, 

33 ATTR_MEDIA_CLIP_URL, 

34 ATTR_MEDIA_SNAPSHOT_URL, 

35 ATTR_MESSAGE_HTML, 

36 ATTR_PERSON_ID, 

37 ATTR_PRIORITY, 

38 ATTR_RECIPIENTS, 

39 ATTR_SCENARIOS_APPLY, 

40 ATTR_SCENARIOS_CONSTRAIN, 

41 ATTR_SCENARIOS_REQUIRE, 

42 ATTR_SPOKEN_MESSAGE, 

43 ATTR_VIDEO, 

44 DELIVERY_SELECTION_EXPLICIT, 

45 DELIVERY_SELECTION_FIXED, 

46 DELIVERY_SELECTION_IMPLICIT, 

47 DRY_RUN_LIVE, 

48 DRY_RUN_SIMULATE, 

49 INCLUSION_DEFAULT, 

50 PRIORITY_MEDIUM, 

51 PRIORITY_VALUES, 

52 TARGET_USE_FIXED, 

53 TARGET_USE_MERGE_ALWAYS, 

54 TARGET_USE_MERGE_ON_DELIVERY_TARGETS, 

55 TARGET_USE_ON_NO_ACTION_TARGETS, 

56 TARGET_USE_ON_NO_DELIVERY_TARGETS, 

57) 

58from .envelope import Envelope 

59from .media_grab import snap_notification_image as _snap_notification_image 

60from .model import ( 

61 ConditionVariables, 

62 DebugTrace, 

63 DeliveryCustomization, 

64 SuppressionReason, 

65 Target, 

66 TargetRequired, 

67 TransportFeature, 

68) 

69from .schema import ACTION_DATA_SCHEMA, STRICT_ACTION_DATA_SCHEMA, DeliveryOutcome, EnvelopeOutcome, OutcomeSelection 

70from .target import TargetEntityCategory 

71 

72if TYPE_CHECKING: 

73 from homeassistant.core import Context as HAContext 

74 

75 from .context import Context 

76 from .delivery import Delivery, DeliveryRegistry 

77 from .hass_api import HomeAssistantAPI 

78 from .people import PeopleRegistry, Recipient 

79 from .scenario import Scenario 

80 from .transport import ( 

81 Transport, 

82 ) 

83 

84_LOGGER = logging.getLogger(__name__) 

85 

86_VERSION: str = "unknown" 

87 

88 

89def set_version(version: str) -> None: 

90 """Called once from async_setup_entry with the integration's manifest version. 

91 

92 Avoids a blocking manifest.json read on the event loop at import time. 

93 """ 

94 global _VERSION 

95 _VERSION = version 

96 

97 

98# supernotify specific data items not to be passed to transports in data 

99# the existence of this is probably an indication of something not quite right 

100 

101type DeliveryName = str 

102 

103 

104@dataclass 

105class DeliveryTargetOverride: 

106 fixed: list[str] 

107 include: list[str] 

108 exclude: list[str] 

109 

110 def as_dict(self, **_kwargs: Any) -> dict[str, Any]: 

111 return {"fixed": self.fixed, "include": self.include, "exclude": self.exclude} 

112 

113 

114def _original_trace() -> dict[str, str] | None: 

115 """The automation or script run in progress when the notification was made, for a link to its trace. 

116 

117 Home Assistant sets this for the task an automation or script runs in, and the tasks and event 

118 listeners it starts, and never clears it, so it is the run in progress rather than proof of the 

119 sender - compare its trace's context with `original_context` to be sure. There's none for a call 

120 from the developer tools, the API or another integration's own task, and an automation without an 

121 `id` gets one keyed `automation.None` but no stored trace to link to""" 

122 trace_id: tuple[str, str] | None = trace_id_get() 

123 if trace_id is None or trace_id[0].endswith(".None"): 

124 return None 

125 return {"key": trace_id[0], "run_id": trace_id[1]} 

126 

127 

128class Notification(ArchivableObject): 

129 def __init__( 

130 self, 

131 context: Context, 

132 message: str | None = None, 

133 title: str | None = None, 

134 target: list[str] | str | dict[str, Any] | None = None, 

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

136 ha_context: HAContext | None = None, 

137 ) -> None: 

138 self.created: dt.datetime = dt.datetime.now(tz=dt_util.get_default_time_zone()) 

139 self.debug_trace: DebugTrace = DebugTrace( 

140 message=message, 

141 title=title, 

142 data=action_data, 

143 target=target, 

144 debug=bool((action_data or {}).get(ATTR_DEBUG, False)), 

145 ) 

146 self.message: str | None = message 

147 self.context: Context = context 

148 self.ha_context: HAContext | None = ha_context 

149 self.original_trace: dict[str, str] | None = _original_trace() 

150 self._senders: list[str] | None = None 

151 self.people_registry: PeopleRegistry = context.people_registry 

152 self.delivery_registry: DeliveryRegistry = context.delivery_registry 

153 action_data = action_data or {} 

154 

155 if action_data.get(ATTR_RECIPIENTS) and not isinstance(target, dict): 

156 _LOGGER.warning("SUPERNOTIFY recipients key in notify action data deprecated, list recipients in target instead") 

157 target = ensure_list(target) + ensure_list(action_data.get(ATTR_RECIPIENTS)) 

158 

159 # what to leave out of the target, from the target selector's 'Exclude target' in Home 

160 # Assistant 2026.11 - resolved on first use, see _without_excluded() 

161 self._exclude: dict[str, Any] | None = None 

162 self._excluded: Target | None = None 

163 if isinstance(target, dict) and CONF_EXCLUDE in target: 

164 target = dict(target) 

165 self._exclude = target.pop(CONF_EXCLUDE) or None 

166 

167 # an explicitly empty list of targets is a choice to have none, so the recipients aren't 

168 # used by default, leaving only targets from deliveries and scenarios. A missing or blank 

169 # target is not that choice. 

170 self._no_default_recipients: bool = isinstance(target, list) and not target 

171 target, action_data = self.convert_exposed_notify_entities(target, action_data) 

172 self._target: Target | None = Target(self.convert_notify_entities(target)) if target else None 

173 # targeting this integration's own device is never a real target - treat it as a request to 

174 # also apply each default delivery's own target alongside whatever else was targeted (see 

175 # HomeAssistantAPI.is_own_device and the merge in generate_targets) 

176 self._apply_delivery_defaults: bool = False 

177 if self._target and self._target.device_ids: 

178 own_device_ids = [d for d in self._target.device_ids if context.hass_api.is_own_device(d)] 

179 if own_device_ids: 

180 self._target.remove(ATTR_DEVICE_ID, own_device_ids) 

181 self._apply_delivery_defaults = True 

182 self._already_selected: Target = Target() 

183 self._recorded_person_ids: set[str] = set() # recipients whose notify entity this notification has updated 

184 self._reached_person_ids: set[str] = set() # recipients with a target in an envelope, see _attach_person_ids() 

185 self._title: str | None = title 

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

187 self.delivered: int = 0 

188 self.error_count: int = 0 

189 self.skipped: int = 0 

190 self.missed: int = 0 

191 self.failed: int = 0 

192 self.suppressed: int = 0 

193 self.fallback: int = 0 

194 self.dupe: bool = False 

195 self.deliveries: dict[DeliveryName, dict[EnvelopeOutcome, list[str] | list[Envelope] | dict[str, Any]]] = {} 

196 self.delivery_exceptions: dict[DeliveryName, list[str]] = {} 

197 self.uncategorized_targets: dict[DeliveryName, list[str]] = {} 

198 self._skip_reasons: list[SuppressionReason] = [] 

199 

200 self.validate_action_data(action_data) 

201 # for compatibility with other notify calls, pass thru surplus data to underlying delivery transports 

202 self.extra_data: dict[str, Any] = { 

203 k: v for k, v in action_data.items() if k not in STRICT_ACTION_DATA_SCHEMA(action_data) 

204 } 

205 

206 action_data = {k: v for k, v in action_data.items() if k not in self.extra_data} 

207 self.extra_data.update(action_data.get(ATTR_DATA, {})) # nested `data` could be supernotify or target service 

208 

209 self.priority: str = canonical_priority(action_data.get(ATTR_PRIORITY, PRIORITY_MEDIUM)) 

210 self.message_html: str | None = action_data.get(ATTR_MESSAGE_HTML) 

211 self.spoken_message: str | None = action_data.get(ATTR_SPOKEN_MESSAGE) 

212 self.force_resend: bool = action_data.get(ATTR_FORCE_RESEND, False) 

213 self.dry_run: str = action_data.get(ATTR_DRY_RUN, DRY_RUN_LIVE) 

214 self.required_scenario_names: list[str] = ensure_list(action_data.get(ATTR_SCENARIOS_REQUIRE)) 

215 self.applied_scenario_names: list[str] = ensure_list(action_data.get(ATTR_SCENARIOS_APPLY)) 

216 self.constrain_scenario_names: list[str] = ensure_list(action_data.get(ATTR_SCENARIOS_CONSTRAIN)) 

217 self.delivery_selection: str | None = action_data.get(ATTR_DELIVERY_SELECTION) 

218 self.delivery_overrides: dict[str, DeliveryCustomization] = {} 

219 

220 delivery_data: Any | None = action_data.get(ATTR_DELIVERY) 

221 if self.delivery_selection is None: 

222 # an empty list is a choice of no deliveries of its own, leaving only those a scenario 

223 # turns on, just as an empty list of targets leaves only those from deliveries and scenarios 

224 if isinstance(delivery_data, list) or (isinstance(delivery_data, str) and delivery_data): 

225 self.delivery_selection = DELIVERY_SELECTION_EXPLICIT 

226 else: 

227 self.delivery_selection = DELIVERY_SELECTION_IMPLICIT 

228 if isinstance(delivery_data, list): 

229 # a bare list of deliveries implies intent to restrict 

230 _LOGGER.debug("SUPERNOTIFY Defaulting delivery selection as explicit for list %s", delivery_data) 

231 self.delivery_overrides = { 

232 k: DeliveryCustomization(config=None, default_enabled=True) for k in action_data.get(ATTR_DELIVERY, []) 

233 } 

234 elif isinstance(delivery_data, str) and delivery_data: 

235 # a bare list of deliveries implies intent to restrict 

236 _LOGGER.debug("SUPERNOTIFY Defaulting delivery selection as explicit for single %s", delivery_data) 

237 self.delivery_overrides = {delivery_data: DeliveryCustomization(config=None, default_enabled=True)} 

238 elif isinstance(delivery_data, dict): 

239 # whereas a dict may be used to tune or restrict 

240 _LOGGER.debug("SUPERNOTIFY Defaulting delivery selection as implicit for mapping %s", delivery_data) 

241 self.delivery_overrides = { 

242 k: DeliveryCustomization(config={**(v or {})}, default_enabled=True) 

243 for k, v in action_data.get(ATTR_DELIVERY, {}).items() 

244 } 

245 elif delivery_data: 

246 _LOGGER.warning("SUPERNOTIFY Unable to interpret delivery data %s", delivery_data) 

247 

248 self.action_groups: list[str] | None = nullable_ensure_list(action_data.get(ATTR_ACTION_GROUPS)) 

249 self.media: dict[str, Any] = action_data.get(ATTR_MEDIA) or {} 

250 self.debug: bool = action_data.get(ATTR_DEBUG, False) 

251 self.actions: list[dict[str, Any]] = ensure_list(action_data.get(ATTR_ACTIONS)) 

252 

253 self.selected_deliveries: dict[str, DeliveryTargetOverride | None] = {} 

254 self.implicit_only_deliveries: set[str] = set() 

255 self.enabled_scenarios: dict[str, Scenario] = {} 

256 self.selected_scenario_names: list[str] = [] 

257 self._suppression_reason: SuppressionReason | None = None 

258 self._raw_image_path: Any = None 

259 self._delivery_error: list[str] | None = None 

260 self.condition_variables: ConditionVariables 

261 self.unknown_names: dict[str, list[str]] = self._unknown_names() 

262 

263 def _unknown_names(self) -> dict[str, list[str]]: 

264 """Delivery and scenario names in the call that aren't configured, which are otherwise ignored. 

265 

266 An unknown applied or constraining scenario is only logged at debug, so a caller can name 

267 scenarios that the user may or may not have chosen to configure""" 

268 if not ( 

269 self.delivery_overrides 

270 or self.required_scenario_names 

271 or self.applied_scenario_names 

272 or self.constrain_scenario_names 

273 ): 

274 return {} 

275 registry: DeliveryRegistry = self.delivery_registry 

276 known_deliveries: set[str] = {*registry.deliveries, *registry.transports} 

277 scenarios: list[str] = list(self.context.scenario_registry.scenarios) 

278 requested: dict[str, tuple[list[str], list[str]]] = { 

279 ATTR_DELIVERY: ( 

280 [n for n in self.delivery_overrides if registry.resolve_name(n) not in known_deliveries], 

281 list(registry.deliveries), 

282 ), 

283 ATTR_SCENARIOS_REQUIRE: ([n for n in self.required_scenario_names if n not in scenarios], scenarios), 

284 ATTR_SCENARIOS_APPLY: ([n for n in self.applied_scenario_names if n not in scenarios], scenarios), 

285 ATTR_SCENARIOS_CONSTRAIN: ([n for n in self.constrain_scenario_names if n not in scenarios], scenarios), 

286 } 

287 unknown: dict[str, list[str]] = {} 

288 for field, (names, valid) in requested.items(): 

289 if names: 

290 _LOGGER.log( 

291 logging.WARNING if field in (ATTR_DELIVERY, ATTR_SCENARIOS_REQUIRE) else logging.DEBUG, 

292 "SUPERNOTIFY Ignoring unknown %s %s, configured names are: %s", 

293 field, 

294 ", ".join(names), 

295 ", ".join(valid) or "none", 

296 ) 

297 unknown[field] = names 

298 return unknown 

299 

300 async def initialize(self) -> None: 

301 """Async post-construction initialization""" 

302 self.occupancy: dict[str, list[Recipient]] = self.people_registry.determine_occupancy() 

303 self.condition_variables = ConditionVariables( 

304 self.applied_scenario_names, 

305 self.required_scenario_names, 

306 self.constrain_scenario_names, 

307 self.priority, 

308 self.occupancy, 

309 self.message, 

310 self._title, 

311 self.extra_data, 

312 self.camera_variables(), 

313 self.actions, 

314 self.action_groups, 

315 ) # requires occupancy first 

316 

317 enabled_scenario_names: list[str] = list(self.applied_scenario_names) or [] 

318 self.selected_scenario_names = await self.select_scenarios() 

319 enabled_scenario_names.extend(self.selected_scenario_names) 

320 if self.constrain_scenario_names: 

321 enabled_scenario_names = [ 

322 s for s in enabled_scenario_names if (s in self.constrain_scenario_names or s in self.applied_scenario_names) 

323 ] 

324 if self.required_scenario_names and not any(s in enabled_scenario_names for s in self.required_scenario_names): 

325 _LOGGER.info("SUPERNOTIFY Suppressing notification, no required scenarios enabled") 

326 self.selected_deliveries = {} 

327 self.suppress(SuppressionReason.NO_SCENARIO) 

328 else: 

329 for s in enabled_scenario_names: 

330 scenario_obj: Scenario | None = self.context.scenario_registry.scenarios.get(s) 

331 if scenario_obj is not None: 

332 self.enabled_scenarios[s] = scenario_obj 

333 

334 self.selected_deliveries = self.select_deliveries() 

335 if self.context.snoozer.is_global_snooze(self.priority): 

336 self.suppress(SuppressionReason.SNOOZED) 

337 self.apply_enabled_scenarios() 

338 

339 if not self.media: 

340 self.media = self.media_requirements(self.extra_data) 

341 # a scenario may have chosen the camera, too late for conditions but in time for templates 

342 self.condition_variables.camera = self.camera_variables() 

343 

344 def camera_variables(self) -> dict[str, Any]: 

345 """Camera of the notification for conditions and templates, with its `cameras` config if there is any""" 

346 entity_id: str | None = (self.media or {}).get(ATTR_MEDIA_CAMERA_ENTITY_ID) 

347 if not entity_id: 

348 return {} 

349 return {**self.context.cameras.get(entity_id, {}), "entity_id": entity_id} 

350 

351 def _exposed_notify_entities(self) -> dict[str, tuple[str, str]]: 

352 """The notify entity of each scenario or delivery that has one, to its action data key and name""" 

353 return { 

354 exposable.notify_entity_id: (data_key, name) 

355 for data_key, configured in ( 

356 (ATTR_SCENARIOS_APPLY, self.context.scenario_registry.scenarios), 

357 (ATTR_DELIVERY, self.delivery_registry.deliveries), 

358 ) 

359 for name, exposable in configured.items() 

360 if exposable.notify_entity_id 

361 } 

362 

363 def _short_circuit_own_entities(self, target: Target, delivery: Delivery) -> Target: 

364 """Short circuit this integration's own notify entities in a target that came from configuration, 

365 such as a delivery, scenario or recipient target, as convert_notify_entities() and 

366 convert_exposed_notify_entities() do for the action's own target, so they never go out through 

367 notify.send_message and come back as a new notification, possibly in a loop. 

368 

369 A recipient's notify entity is replaced by the recipient, and their own targets for this 

370 delivery. A scenario or delivery's notify entity is dropped, since by now it's too late to 

371 apply the scenario or select the delivery. 

372 """ 

373 exposed: dict[str, tuple[str, str]] = self._exposed_notify_entities() 

374 recipients: dict[str, Recipient] = self.people_registry.notify_entities() 

375 own: list[str] = [e for e in target.entity_ids if e in exposed or e in recipients] 

376 if not own: 

377 return target 

378 target = target.safe_copy() 

379 target.remove(ATTR_ENTITY_ID, own) 

380 for entity_id in own: 

381 if entity_id in exposed: 

382 _LOGGER.warning( 

383 "SUPERNOTIFY Dropping %s from %s targets, Supernotify's own notify entity for %s %s", 

384 entity_id, 

385 delivery.name, 

386 *exposed[entity_id], 

387 ) 

388 person_ids: list[str] = [ 

389 p 

390 for p in dict.fromkeys(recipients[e].entity_id for e in own if e in recipients) 

391 if p and p not in target.person_ids 

392 ] 

393 if person_ids: 

394 people: Target = self.context.snoozer.filter_recipients( 

395 Target({ATTR_PERSON_ID: person_ids}), 

396 self.priority, 

397 delivery, 

398 self.media.get(ATTR_MEDIA_CAMERA_ENTITY_ID), 

399 tags=self.snooze_tags(), 

400 ) 

401 target += people 

402 for indirect_target in self.resolve_indirect_targets(people, delivery): 

403 target += indirect_target 

404 # only one level, so a recipient whose own targets name one of these can't recurse 

405 again: list[str] = [e for e in target.entity_ids if e in exposed or e in recipients] 

406 if again: 

407 target.remove(ATTR_ENTITY_ID, again) 

408 return target 

409 

410 def convert_exposed_notify_entities( 

411 self, target: list[str] | str | dict[str, Any] | None, action_data: dict[str, Any] 

412 ) -> tuple[list[str] | str | dict[str, Any] | None, dict[str, Any]]: 

413 """Short circuit the notify entity of a scenario or delivery given as a target, see 

414 ExposedNotifyEntity in notify.py. It is taken out of the targets, and its scenario applied 

415 or delivery selected just as the entity itself would, rather than the entity being called 

416 and making a second notification. 

417 """ 

418 if not target: 

419 return target, action_data 

420 exposed: dict[str, tuple[str, str]] = self._exposed_notify_entities() 

421 entity_ids: list[str] = ensure_list(target.get(ATTR_ENTITY_ID) if isinstance(target, dict) else target) 

422 found: list[str] = [e for e in entity_ids if e in exposed] 

423 if not found: 

424 return target, action_data 

425 remaining: list[str] = [e for e in entity_ids if e not in exposed] 

426 target = {**target, ATTR_ENTITY_ID: remaining} if isinstance(target, dict) else remaining 

427 action_data = dict(action_data) 

428 for data_key, name in (exposed[e] for e in found): 

429 if isinstance(action_data.get(data_key), dict): 

430 action_data[data_key] = {name: None, **action_data[data_key]} 

431 else: 

432 action_data[data_key] = [*ensure_list(action_data.get(data_key)), name] 

433 return target, action_data 

434 

435 def convert_notify_entities( 

436 self, target: list[str] | str | dict[str, Any] | None = None 

437 ) -> list[str] | str | dict[str, Any] | None: 

438 """Short circuit supernotify notify entities so they're handled directly so not 

439 going round in circles via calls to notify.send_message. A genuine other-integration 

440 notify entity is left alone for NotifyEntityTransport to handle normally. 

441 

442 Defined here rather than in models/Target since requires access to registries. 

443 

444 supernotify.notify's `target:` field also accepts the dict shape Home Assistant's own 

445 target selector produces, e.g. {"entity_id": ["person.jey", "notify.recipient_alice"]} - 

446 unlike this integration's other dict-shaped targets (recipient/delivery config), that 

447 `entity_id` list is Home Assistant's raw picker output, not pre-sorted by category: a 

448 person entity picked that way still needs to end up as a `person_id`, same as if it had 

449 been typed directly into notify.supernotify's flat target list, or it would otherwise be 

450 silently dropped by Target() (whose entity_id category explicitly excludes the person 

451 domain - see Target.is_entity_id/is_person_id in model.py). 

452 """ 

453 if not target: 

454 return target 

455 known_entities: dict[str, Recipient] = self.people_registry.notify_entities() 

456 

457 if isinstance(target, dict): 

458 entity_ids = ensure_list(target.get(ATTR_ENTITY_ID)) 

459 if not entity_ids: 

460 return target 

461 remaining_entity_ids: list[str] = [] 

462 person_ids: list[str] = list(ensure_list(target.get(ATTR_PERSON_ID))) 

463 for e in entity_ids: 

464 if e in known_entities: 

465 # e.g. switch to a person id if a notify entity for recipient 

466 person_ids.append(known_entities[e].entity_id) 

467 elif Target.is_person_id(e): 

468 person_ids.append(e) 

469 else: 

470 remaining_entity_ids.append(e) 

471 updated_target: dict[str, list[str] | Any] = {**target, ATTR_ENTITY_ID: remaining_entity_ids} 

472 if person_ids: 

473 updated_target[ATTR_PERSON_ID] = person_ids 

474 return updated_target 

475 

476 updated: list[str] = [] 

477 changed: bool = False 

478 for t in ensure_list(target): 

479 if t in known_entities: 

480 # e.g. switch to a person id if a notify entity for recipient 

481 updated.append(known_entities[t].entity_id) 

482 changed = True 

483 else: 

484 updated.append(t) 

485 

486 if changed: 

487 return updated 

488 return target 

489 

490 def outcome(self) -> DeliveryOutcome: 

491 if self.error_count > 0: 

492 return DeliveryOutcome.ERROR 

493 if not self.delivered: 

494 # only a dupe if nothing was sent, otherwise what was sent is the outcome 

495 return DeliveryOutcome.DUPE if self.dupe else DeliveryOutcome.NO_DELIVERY 

496 if self.fallback: 

497 return DeliveryOutcome.FALLBACK_DELIVERY 

498 if self.missed: 

499 return DeliveryOutcome.PARTIAL_DELIVERY 

500 return DeliveryOutcome.SUCCESS 

501 

502 def diagnostics_selected(self, outcome_policy: OutcomeSelection) -> bool: 

503 """A notification sent with `debug: true` asked for its trace, so it is archived 

504 with the full diagnostic content whatever the configured `diagnostics` outcomes""" 

505 return bool(self.debug) or super().diagnostics_selected(outcome_policy) 

506 

507 def media_requirements(self, data: dict[str, Any]) -> dict[str, Any]: 

508 """If no media defined, look for iOS / Android actions that have media defined 

509 

510 Example is the Frigate blueprint, which generates `image`, `video` etc 

511 in the `data` section, that can also be used for email attachments 

512 """ 

513 media_dict: dict[str, Any | None] = {} 

514 if not data: 

515 return {} 

516 if data.get(ATTR_IMAGE): 

517 media_dict[ATTR_MEDIA_SNAPSHOT_URL] = data.get(ATTR_IMAGE) 

518 if data.get(ATTR_VIDEO): 

519 media_dict[ATTR_MEDIA_CLIP_URL] = data.get(ATTR_VIDEO) 

520 if data.get("attachment", {}).get("url"): 

521 url = data["attachment"]["url"] 

522 if url and url.endswith(".mp4") and not media_dict.get(ATTR_MEDIA_CLIP_URL): 

523 media_dict[ATTR_MEDIA_CLIP_URL] = url 

524 elif url and (url.endswith((".jpg", ".jpeg", ".png"))) and not media_dict.get(ATTR_MEDIA_SNAPSHOT_URL): 

525 media_dict[ATTR_MEDIA_SNAPSHOT_URL] = url 

526 return media_dict 

527 

528 def validate_action_data(self, action_data: dict[str, Any]) -> None: 

529 if action_data.get(ATTR_PRIORITY): 

530 if isinstance(action_data.get(ATTR_PRIORITY), (str, int, float)): 

531 if canonical_priority(action_data.get(ATTR_PRIORITY)) not in PRIORITY_VALUES: 

532 _LOGGER.info("SUPERNOTIFY Custom priority %s", action_data.get(ATTR_PRIORITY)) 

533 else: 

534 _LOGGER.info("SUPERNOTIFY Invalid priority %s", action_data.get(ATTR_PRIORITY)) 

535 self.suppress(SuppressionReason.INVALID_ACTION_DATA) 

536 raise vol.Invalid("Priority value must be a simple value") 

537 try: 

538 humanize.validate_with_humanized_errors(action_data, cast("vol.Schema", ACTION_DATA_SCHEMA)) 

539 except vol.Invalid as e: 

540 _LOGGER.warning("SUPERNOTIFY Invalid action data %s: %s", action_data, e) 

541 self.suppress(SuppressionReason.INVALID_ACTION_DATA) 

542 raise 

543 except vol.error.Error as e2: 

544 _LOGGER.warning("SUPERNOTIFY Failed to validate action data %s: %s", action_data, e2) 

545 self.suppress(SuppressionReason.INVALID_ACTION_DATA) 

546 raise vol.Invalid(f"Unable to validate action data - {e2}") from e2 

547 

548 def apply_enabled_scenarios(self) -> None: 

549 """Set media and action_groups from scenario if defined, first come first applied""" 

550 action_groups: list[str] = [] 

551 for scenario in self.enabled_scenarios.values(): 

552 if scenario.media: 

553 if self.media: 

554 self.media.update(scenario.media) 

555 else: 

556 self.media = scenario.media 

557 if scenario.action_groups: 

558 action_groups.extend(ag for ag in scenario.action_groups if ag not in action_groups) 

559 # self.action_groups only accessed from inside Envelope 

560 if self.action_groups: 

561 self.action_groups.extend(action_groups) 

562 else: 

563 self.action_groups = action_groups 

564 

565 def select_deliveries(self) -> dict[str, DeliveryTargetOverride | None]: 

566 # 'DEFAULT_x' backward compatibility: a delivery referenced by its bare 

567 # transport name resolves to the auto-configured 'DEFAULT_x', if that's what 

568 # actually exists in the registry. 

569 resolve_name = self.context.delivery_registry.resolve_name 

570 

571 scenario_enable_deliveries: list[str] = [] 

572 scenario_disable_deliveries: list[str] = [] 

573 default_enable_deliveries: list[str] = [] 

574 recipients_enable_deliveries: list[str] = [] 

575 recipients_disable_deliveries: list[str] = [] 

576 all_recipients: list[Recipient] = self.all_recipients() 

577 

578 trace: DebugTrace = self.debug_trace 

579 if self.delivery_selection != DELIVERY_SELECTION_FIXED: 

580 for scenario in self.enabled_scenarios.values(): 

581 for d in scenario.enabling_deliveries(): 

582 name = resolve_name(d) 

583 scenario_enable_deliveries.append(name) 

584 trace.record_delivery_provenance(name, "enabled_by", f"scenario:{scenario.name}") 

585 for scenario in self.enabled_scenarios.values(): 

586 for d in scenario.disabling_deliveries(): 

587 scenario_disable_deliveries.append(resolve_name(d)) 

588 

589 scenario_enable_deliveries = list(dict.fromkeys(scenario_enable_deliveries)) 

590 scenario_disable_deliveries = list(dict.fromkeys(scenario_disable_deliveries)) 

591 

592 for recipient in all_recipients: 

593 for d in recipient.enabling_delivery_names(): 

594 name = resolve_name(d) 

595 recipients_enable_deliveries.append(name) 

596 trace.record_delivery_provenance(name, "enabled_by", f"recipient:{recipient.name}") 

597 # See also Recipient.target() for the disabled cases 

598 for d in recipient.disabling_delivery_names(): 

599 name = resolve_name(d) 

600 recipients_disable_deliveries.append(name) 

601 trace.record_delivery_provenance(name, "disabled_by", f"recipient:{recipient.name}") 

602 if self.delivery_selection == DELIVERY_SELECTION_IMPLICIT: 

603 # implicit_deliveries is already narrowed to transports declaring real 

604 # unique/fallback categories (or a delivery with explicit_inclusion, exempt from 

605 # the check - see implicit_deliveries) - here that's checked against what this 

606 # specific notification, the candidate delivery's own configured target, and its 

607 # recipients can actually offer, a cheap presence check, not full target 

608 # resolution (see _category_satisfied()) 

609 notify_entity_ids: list[str] = list(self.people_registry.notify_entities()) 

610 # targeting this integration's own device (see __init__/_apply_delivery_defaults) 

611 # is a request to also apply every default delivery's own recipients, same as an 

612 # empty target - all_recipients() alone wouldn't see that, since the call's own 

613 # target has no person_ids for it to match against 

614 pool_recipients: list[Recipient] = ( 

615 self.people_registry.enabled_recipients() if self._apply_delivery_defaults else all_recipients 

616 ) 

617 for candidate in self.context.delivery_registry.implicit_deliveries: 

618 if candidate.explicit_inclusion: 

619 default_enable_deliveries.append(candidate.name) 

620 trace.record_delivery_provenance(candidate.name, "enabled_by", "default") 

621 continue 

622 pool: Target = self._target.safe_copy() if self._target else Target() 

623 if candidate.target: 

624 pool += candidate.target 

625 for recipient in pool_recipients: 

626 pool += recipient.target(candidate.name) 

627 if notify_entity_ids: 

628 pool.extend(ATTR_ENTITY_ID, notify_entity_ids) 

629 if self._category_satisfied(candidate.transport.unique_target_categories, pool) or self._category_satisfied( 

630 candidate.transport.fallback_target_categories, pool 

631 ): 

632 default_enable_deliveries.append(candidate.name) 

633 trace.record_delivery_provenance(candidate.name, "enabled_by", "default") 

634 

635 trace.record_delivery_selection("scenario_enable_deliveries", scenario_enable_deliveries) 

636 trace.record_delivery_selection("scenario_disable_deliveries", scenario_disable_deliveries) 

637 trace.record_delivery_selection("default_enable_deliveries", default_enable_deliveries) 

638 trace.record_delivery_selection("recipient_enable_deliveries", recipients_enable_deliveries) 

639 

640 override_enable_deliveries: list[str] = [] 

641 override_disable_deliveries: list[str] = [] 

642 

643 # apply the deliveries defined in the notification action call 

644 for delivery_name, delivery_override in self.delivery_overrides.items(): 

645 delivery = resolve_name(delivery_name) 

646 if ( 

647 (delivery_override is None or delivery_override.enabled is not False) 

648 and delivery in self.context.delivery_registry.enabled_deliveries 

649 ) or ( 

650 (delivery_override is not None and delivery_override.enabled is not False) 

651 and delivery in self.context.delivery_registry.disabled_deliveries 

652 ): 

653 override_enable_deliveries.append(delivery) 

654 trace.record_delivery_provenance(delivery, "enabled_by", "call") 

655 elif delivery_override is not None and delivery_override.enabled is False: 

656 override_disable_deliveries.append(delivery) 

657 trace.record_delivery_provenance(delivery, "disabled_by", "call") 

658 

659 all_global_enabled: list[str] = list( 

660 dict.fromkeys(scenario_enable_deliveries + default_enable_deliveries + override_enable_deliveries) 

661 ) 

662 all_enabled: list[str] = all_global_enabled + recipients_enable_deliveries 

663 # outside debug, only trace a scenario disable that actually switched something off - a 

664 # wildcard disable otherwise names every delivery it matched, selected or not 

665 for scenario in self.enabled_scenarios.values(): 

666 for d in scenario.disabling_deliveries(): 

667 name = resolve_name(d) 

668 if name in scenario_disable_deliveries and (self.debug or name in all_enabled): 

669 trace.record_delivery_provenance(name, "disabled_by", f"scenario:{scenario.name}") 

670 # override_enable_deliveries takes precedence: if the action call explicitly 

671 # re-enables a delivery that a scenario disabled, remove it from all_disabled. 

672 all_disabled: list[str] = [ 

673 d for d in scenario_disable_deliveries + override_disable_deliveries if d not in override_enable_deliveries 

674 ] 

675 override_enabled: list[str] = list(dict.fromkeys(scenario_enable_deliveries + override_enable_deliveries)) 

676 trace.record_delivery_selection("override_disable_deliveries", override_disable_deliveries) 

677 trace.record_delivery_selection("override_enable_deliveries", override_enable_deliveries) 

678 

679 unsorted_maybe_objs: list[Delivery | None] = [ 

680 self.delivery_registry.deliveries.get(d) for d in all_enabled if d not in all_disabled 

681 ] 

682 unsorted_objs: list[Delivery] = [] 

683 for maybe_obj in unsorted_maybe_objs: 

684 if maybe_obj is None: 

685 continue 

686 if not maybe_obj.transport.enabled: 

687 # a scenario can override a delivery's own enabled flag, but never its transport's - 

688 # a disabled transport is excluded outright, before any envelope is built 

689 trace.record_delivery_provenance(maybe_obj.name, "disabled_by", f"transport:{maybe_obj.transport.name}") 

690 continue 

691 if maybe_obj.enabled or maybe_obj.name in override_enabled: 

692 unsorted_objs.append(maybe_obj) 

693 

694 explicitly_enabled: set[str] = { 

695 *scenario_enable_deliveries, 

696 *override_enable_deliveries, 

697 *recipients_enable_deliveries, 

698 } 

699 # a delivery only ever included by default (never asked for by call, scenario or 

700 # recipient) that turns out to have nothing to target is a routine non-event, not a 

701 # miss - see delivery_skip_reason()/record_result(). Unlike transport/enabled above, 

702 # this can't be excluded from selection itself: whether a target actually resolves is 

703 # call-specific runtime data (who's a recipient, who's home), not a standing rule, and 

704 # plenty of tests/behaviour deliberately rely on selection being independent of that. 

705 self.implicit_only_deliveries = { 

706 d.name for d in unsorted_objs if d.name in default_enable_deliveries and d.name not in explicitly_enabled 

707 } 

708 

709 def tie_break(d: Delivery) -> tuple[bool, bool]: 

710 # within a rank, a fallback-category delivery sorts after its bucket-mates even 

711 # under an explicit rank override ("last of the first") - it should win ties among 

712 # equally-ranked peers, not jump ahead of a delivery that's unique by construction. 

713 # A user's own CONFIG-provenance delivery also sorts before an auto-generated 

714 # standard one at the same rank - a deliberate choice beats an auto-inferred default. 

715 return (self._is_fallback_only(d), d.provenance != DeliveryProvenance.CONFIG) 

716 

717 first: list[str] = [d.name for d in sorted(unsorted_objs, key=tie_break) if d.selection_rank == SelectionRank.FIRST] 

718 anywhere: list[str] = [d.name for d in sorted(unsorted_objs, key=tie_break) if d.selection_rank == SelectionRank.ANY] 

719 last: list[str] = [d.name for d in sorted(unsorted_objs, key=tie_break) if d.selection_rank == SelectionRank.LAST] 

720 selected: list[str] = first + anywhere + last 

721 trace.record_delivery_selection("ranked", selected) 

722 

723 selected_deliveries: dict[str, DeliveryTargetOverride | None] = dict.fromkeys(selected) 

724 personal_deliveries = [d for d in selected if d in recipients_enable_deliveries] 

725 for personal_delivery in personal_deliveries: 

726 fixed_targets: list[str] = [] 

727 include_targets: list[str] = [] 

728 exclude_targets: list[str] = [] 

729 for recipient in all_recipients: 

730 if personal_delivery in (resolve_name(d) for d in recipient.enabling_delivery_names()): 

731 if personal_delivery in all_global_enabled: 

732 # delivery already selected, make sure this recipient also included 

733 include_targets.append(recipient.entity_id) 

734 else: 

735 # this delivery has been explicitly enabled for individual recipients 

736 fixed_targets.append(recipient.entity_id) 

737 elif personal_delivery in (resolve_name(d) for d in recipient.disabling_delivery_names()): 

738 exclude_targets.append(recipient.entity_id) 

739 selected_deliveries[personal_delivery] = DeliveryTargetOverride( 

740 fixed=fixed_targets, exclude=exclude_targets, include=include_targets 

741 ) 

742 return selected_deliveries 

743 

744 def suppress(self, reason: SuppressionReason) -> None: 

745 self._suppression_reason = reason 

746 if reason not in self._skip_reasons: 

747 self._skip_reasons.append(reason) 

748 _LOGGER.info("SUPERNOTIFY Suppressing notification, reason:%s, id:%s", reason, self.id) 

749 

750 async def deliver(self) -> bool: 

751 _LOGGER.debug( 

752 "Message: %s, notification: %s, deliveries: %s", 

753 self.message, 

754 self.id, 

755 self.selected_deliveries, 

756 ) 

757 

758 if self._suppression_reason is not None: 

759 _LOGGER.info("SUPERNOTIFY Suppressing globally silenced/snoozed notification (%s)", self.id) 

760 for delivery_name in self.selected_deliveries: 

761 delivery = self.context.delivery_registry.deliveries.get(delivery_name) 

762 self.record_result(delivery, suppression_reason=self._suppression_reason) 

763 else: 

764 # Deliveries for transports that call grab_image() are deferred so that 

765 # PTZ movement runs concurrently with non-image deliveries (chime, TTS, etc.) 

766 camera_configured = bool(self.media.get(ATTR_MEDIA_CAMERA_ENTITY_ID) or self.media.get(ATTR_MEDIA_SNAPSHOT_URL)) 

767 immediate_deliveries: dict[str, DeliveryTargetOverride | None] = {} 

768 deferred_deliveries: dict[str, DeliveryTargetOverride | None] = {} 

769 for delivery_name, details in self.selected_deliveries.items(): 

770 d = self.context.delivery_registry.deliveries.get(delivery_name) 

771 if d and camera_configured and d.transport.supported_features & TransportFeature.SNAPSHOT_IMAGE: 

772 deferred_deliveries[delivery_name] = details 

773 else: 

774 immediate_deliveries[delivery_name] = details 

775 

776 # Start image grab immediately so PTZ runs while immediate deliveries execute 

777 image_task: asyncio.Task | None = None 

778 if deferred_deliveries: 

779 image_task = asyncio.create_task(_snap_notification_image(self, self.context, ha_context=self.ha_context)) 

780 

781 _LOGGER.debug("SUPERNOTIFY Scheduling %s immediate deliveries", len(deferred_deliveries)) 

782 await self._schedule_deliveries(immediate_deliveries) 

783 

784 # Ensure image is ready before running image-requiring deliveries 

785 if image_task is not None: 

786 wait_timeout: int = 30 

787 try: 

788 _LOGGER.debug("SUPERNOTIFY Waiting up to %s for image grab to complete", wait_timeout) 

789 async with asyncio.timeout(wait_timeout): # TODO: configurable time-out 

790 await image_task 

791 except Exception: 

792 _LOGGER.exception("SUPERNOTIFY Failed to pre-grab image") 

793 if not image_task.done(): 

794 image_task.cancel() 

795 

796 _LOGGER.debug("SUPERNOTIFY Scheduling %s deferred deliveries", len(deferred_deliveries)) 

797 await self._schedule_deliveries(deferred_deliveries) 

798 await self._fall_back() 

799 

800 if self.delivered == 0 and not self._suppression_reason: 

801 if self.failed == 0 and not self.dupe: 

802 for delivery in self.context.delivery_registry.fallback_by_default_deliveries: 

803 _LOGGER.info( 

804 "SUPERNOTIFY No delivery succeeded, activating fallback_by_default: %s", 

805 delivery.name, 

806 ) 

807 if delivery.name not in self.selected_deliveries and delivery.name not in self.deliveries: 

808 await self.call_transport(delivery) 

809 self.fallback += 1 

810 

811 if self.failed > 0: 

812 for delivery in self.context.delivery_registry.fallback_on_error_deliveries: 

813 _LOGGER.warning( 

814 "SUPERNOTIFY Delivery failed, activating fallback_on_error: %s", 

815 delivery.name, 

816 ) 

817 if delivery.name not in self.selected_deliveries and delivery.name not in self.deliveries: 

818 await self.call_transport(delivery) 

819 self.fallback += 1 

820 

821 return self.delivered > 0 

822 

823 async def _fall_back(self) -> None: 

824 """A delivery's own `fallback:` list - when it errored and sent nothing, try those deliveries 

825 in order until one sends. Only one level: a fallback's own `fallback:` isn't followed, and a 

826 delivery already tried for this notification isn't tried again.""" 

827 registry = self.context.delivery_registry 

828 for name, outcomes in list(self.deliveries.items()): 

829 delivery: Delivery | None = registry.deliveries.get(name) 

830 failed: bool = bool(outcomes.get(EnvelopeOutcome.ERROR)) or name in self.delivery_exceptions 

831 if delivery is None or not delivery.fallback or outcomes.get(EnvelopeOutcome.SUCCESS) or not failed: 

832 continue 

833 for fallback_name in delivery.fallback: 

834 fallback: Delivery | None = registry.deliveries.get(fallback_name) 

835 if fallback is None or not fallback.enabled or fallback_name in self.deliveries: 

836 continue 

837 _LOGGER.info("SUPERNOTIFY Delivery %s failed, falling back to %s", name, fallback_name) 

838 self.debug_trace.record_delivery_provenance(fallback_name, "enabled_by", f"fallback:{name}") 

839 await self.call_transport(fallback, skip_already_sent=True) 

840 self.fallback += 1 

841 if self.deliveries.get(fallback_name, {}).get(EnvelopeOutcome.SUCCESS): 

842 break 

843 

844 async def _schedule_deliveries(self, deliveries: dict[str, DeliveryTargetOverride | None]) -> None: 

845 delivery_coros = [] 

846 for delivery_name, target_overrides in deliveries.items(): 

847 delivery = self.context.delivery_registry.deliveries.get(delivery_name) 

848 if delivery: 

849 delivery_coros.append(self.call_transport(delivery, target_overrides)) 

850 else: 

851 _LOGGER.error("SUPERNOTIFY Unexpected missing delivery %s", delivery_name) 

852 if delivery_coros: 

853 results = await asyncio.gather(*delivery_coros, return_exceptions=True) 

854 for result in results: 

855 if isinstance(result, BaseException): 

856 _LOGGER.error("SUPERNOTIFY Unexpected error in parallel delivery: %s", result) 

857 

858 def snooze_tags(self) -> set[str]: 

859 """What a tag snooze can name this notification by - its scenarios, what the entities it's 

860 about are called, from `entity_id` in its data or its camera, and the automation or script 

861 that sent it""" 

862 tags: set[str] = {spoken_name(s) for s in self.enabled_scenarios} 

863 entity_ids: list[str] = [str(e) for e in ensure_list(self.extra_data.get(ATTR_ENTITY_ID))] 

864 if self.media.get(ATTR_MEDIA_CAMERA_ENTITY_ID): 

865 entity_ids.append(self.media[ATTR_MEDIA_CAMERA_ENTITY_ID]) 

866 if self.context.snoozer.has_tag_snooze(): # the sender is looked up only when it could matter 

867 entity_ids.extend(self.senders()) 

868 for entity_id in entity_ids: 

869 tags.update(self.context.hass_api.entity_spoken_names(entity_id)) 

870 return tags 

871 

872 def senders(self) -> list[str]: 

873 """The automations or scripts that sent this notification, found once from its context - a later 

874 run of the same automation would change the context its state carries""" 

875 if self._senders is None: 

876 self._senders = self.context.hass_api.sender_entity_ids(self.ha_context) 

877 return self._senders 

878 

879 def delivery_skip_reason(self, delivery: Delivery) -> SuppressionReason | None: 

880 """Why a selected delivery won't be attempted at all, checked before any targets are worked out""" 

881 if not delivery.transport.enabled: 

882 return SuppressionReason.TRANSPORT_DISABLED 

883 if self.context.snoozer.is_delivery_snoozed( 

884 self.priority, 

885 delivery, 

886 self.media.get(ATTR_MEDIA_CAMERA_ENTITY_ID), 

887 tags=self.snooze_tags(), 

888 ): 

889 return SuppressionReason.SNOOZED 

890 if self.delivery_selection != DELIVERY_SELECTION_FIXED: 

891 delivery_priorities: list[str] = delivery.priority 

892 if self.priority and delivery_priorities and self.priority not in delivery_priorities: 

893 return SuppressionReason.PRIORITY 

894 if not delivery.evaluate_conditions(self.condition_variables): 

895 return SuppressionReason.DELIVERY_CONDITION 

896 if not self.people_registry.occupancy_permits_delivery(delivery.occupancy, self.occupancy): 

897 return SuppressionReason.OCCUPANCY 

898 return None 

899 

900 def _is_fallback_only(self, delivery: Delivery) -> bool: 

901 """A delivery whose transport only ever claims things as a last resort (e.g. 

902 notify_entity's bare `notify.*` catch-all), rather than because a value/entity is 

903 provably exclusive to it. Unlike a unique-category delivery, such a delivery's 

904 categories don't prove exclusivity on their own, so - unlike before OPTION_UNIQUE_ 

905 TARGETS was removed - it must never re-claim something an earlier-processed delivery 

906 (of any transport) already took; see generate_targets()'s use of this.""" 

907 return not delivery.transport.unique_target_categories and bool(delivery.transport.fallback_target_categories) 

908 

909 def _category_satisfied(self, categories: list[str | TargetEntityCategory], pool: Target) -> bool: 

910 """Whether the match pool has something for any of these categories - a cheap presence 

911 check for implicit selection, not the real target resolution `Target.select()` does 

912 later, so this can be generous (a plain-string category needs a non-empty value list; a 

913 `TargetEntityCategory` needs any pool entity_id whose domain/platform matches, group 

914 entities expanded first since a bare `group.*` id's own domain never matches anything - 

915 it's the members' domain that counts, and area/floor/label selectors resolved to their 

916 entities, so a TV in the targeted area counts).""" 

917 if not categories: 

918 return False 

919 hass_api: HomeAssistantAPI = self.context.hass_api 

920 pool = pool.resolve_selectors(hass_api) 

921 entity_ids: list[str] = hass_api.expand_group(pool.entity_ids) if pool.entity_ids else [] 

922 for category in categories: 

923 if isinstance(category, TargetEntityCategory): 

924 if any(category.matches(entity_id, hass_api) for entity_id in entity_ids): 

925 return True 

926 elif pool.for_category(category): 

927 return True 

928 return False 

929 

930 def _target_required(self, delivery: Delivery) -> TargetRequired: 

931 """delivery.target_required, except an auto-generated standard delivery (never a 

932 user's own CONFIG-provenance one) that's both OPTIONAL and implicit-only for this 

933 call (never asked for by call, scenario or recipient) is treated as ALWAYS, if it 

934 actually declares target_categories. Such a delivery (e.g. mqtt's standard delivery) 

935 has no other chance to prove itself irrelevant to this notification. 

936 

937 Left alone (still OPTIONAL) in two legitimate cases: a delivery with no 

938 target_categories at all (a fixed-action, fire-and-forget `generic` delivery with 

939 nothing to target in the first place); and a delivery someone actually configured or 

940 asked for - a user's own mqtt delivery with a fixed topic baked into its `data:` 

941 (CONFIG provenance), or a call that explicitly names the standard delivery with a 

942 `data:`-only target - both legitimately mean to fire with nothing resolved. 

943 """ 

944 if ( 

945 delivery.target_required == TargetRequired.OPTIONAL 

946 and delivery.provenance != DeliveryProvenance.CONFIG 

947 and delivery.name in self.implicit_only_deliveries 

948 and delivery.target_categories 

949 ): 

950 return TargetRequired.ALWAYS 

951 return delivery.target_required 

952 

953 def _already_sent(self, envelope: Envelope) -> bool: 

954 """Whether another delivery of this notification has already sent exactly this - same transport, 

955 action, targets and content - so sending it again would only repeat it""" 

956 key: str = envelope.content_key() 

957 return any( 

958 sent.content_key() == key 

959 for outcomes in self.deliveries.values() 

960 for sent in outcomes.get(EnvelopeOutcome.SUCCESS) or [] 

961 if isinstance(sent, Envelope) 

962 ) 

963 

964 async def call_transport( 

965 self, 

966 delivery: Delivery, 

967 target_override: DeliveryTargetOverride | None = None, 

968 skip_already_sent: bool = False, 

969 ) -> None: 

970 try: 

971 transport: Transport = delivery.transport 

972 skip_reason: SuppressionReason | None = self.delivery_skip_reason(delivery) 

973 if skip_reason is not None: 

974 self.record_result(delivery, suppression_reason=skip_reason) 

975 _LOGGER.debug("SUPERNOTIFY Skipping delivery %s, %s", delivery.name, skip_reason) 

976 return 

977 

978 targets: list[Target] = self.generate_targets(delivery, target_override) 

979 envelopes: list[Envelope] = self.generate_envelopes(delivery, targets) 

980 if not envelopes: 

981 if self._target_required(delivery) == TargetRequired.ALWAYS and ( 

982 not targets or not any(t.has_resolved_target() for t in targets) 

983 ): 

984 reason: SuppressionReason = SuppressionReason.NO_TARGET 

985 else: 

986 reason = SuppressionReason.UNKNOWN 

987 self.record_result(delivery, targets=targets, suppression_reason=reason) 

988 return 

989 

990 for envelope in envelopes: 

991 if skip_already_sent and self._already_sent(envelope): 

992 # a fallback that would only repeat what another delivery of this notification already sent 

993 _LOGGER.debug("SUPERNOTIFY Skipping %s, already sent the same by another delivery", delivery.name) 

994 self.record_result(delivery, envelope, suppression_reason=SuppressionReason.DUPE) 

995 continue 

996 if not envelope.force_resend and self.context.dupe_checker.check( 

997 envelope, dry_run=self.dry_run == DRY_RUN_SIMULATE 

998 ): 

999 _LOGGER.debug("SUPERNOTIFY Suppressing dupe envelope, %s", self.message) 

1000 self.record_result(delivery, envelope, suppression_reason=SuppressionReason.DUPE) 

1001 continue 

1002 try: 

1003 if self.dry_run == DRY_RUN_SIMULATE: 

1004 _LOGGER.info( 

1005 "SUPERNOTIFY ********** SIMULATE - %s via %s not actually sent **********", 

1006 delivery.name, 

1007 type(transport).__name__, 

1008 ) 

1009 envelope.delivered = 1 

1010 delivered = True 

1011 else: 

1012 delivered = await transport.deliver(envelope, debug_trace=self.debug_trace) 

1013 if delivered: 

1014 self.context.dupe_checker.record(envelope, dry_run=self.dry_run == DRY_RUN_SIMULATE) 

1015 self.record_result(delivery, envelope) 

1016 else: 

1017 _LOGGER.info("SUPERNOTIFY No delivery for %s (id: %s)", delivery.name, envelope.notification_id) 

1018 # keep a reason the transport gave, such as NO_TARGET when every target was skipped 

1019 self.record_result( 

1020 delivery, envelope, suppression_reason=envelope.skip_reason or SuppressionReason.UNKNOWN 

1021 ) 

1022 except Exception as e2: 

1023 envelope.error_count = envelope.error_count + 1 

1024 transport.record_error(str(e2), method="deliver") 

1025 transport.log_delivery_failure(e2, "SUPERNOTIFY Failed to deliver %s", delivery.name) 

1026 envelope.delivery_error = format_exception(e2) 

1027 self.record_result(delivery, envelope) 

1028 

1029 except Exception as e: 

1030 _LOGGER.exception( 

1031 "SUPERNOTIFY Failed to notify using delivery %s via %s", 

1032 delivery.name, 

1033 type(delivery.transport).__name__, 

1034 ) 

1035 self.delivery_exceptions.setdefault(delivery.name, []) 

1036 self.delivery_exceptions[delivery.name].append("\n".join(format_exception(e))) 

1037 self.record_result(delivery, None, suppression_reason=SuppressionReason.ERROR) 

1038 

1039 def record_result( 

1040 self, 

1041 delivery: Delivery | None, 

1042 envelope: Envelope | None = None, 

1043 targets: list[Target] | None = None, 

1044 suppression_reason: SuppressionReason | None = None, 

1045 ) -> None: 

1046 """Debugging (and unit test) support for notifications that failed or were skipped""" 

1047 if delivery and envelope: 

1048 self.delivered += envelope.delivered 

1049 self.error_count += envelope.error_count 

1050 self.deliveries.setdefault(delivery.name, {}) 

1051 if envelope.delivered: 

1052 self.deliveries[delivery.name].setdefault(EnvelopeOutcome.SUCCESS, []) 

1053 self.deliveries[delivery.name][EnvelopeOutcome.SUCCESS].append(envelope) # type: ignore 

1054 envelope.record_recipient_notifications(self._recorded_person_ids) 

1055 else: 

1056 if suppression_reason: 

1057 envelope.skip_reason = suppression_reason 

1058 if suppression_reason not in self._skip_reasons: 

1059 self._skip_reasons.append(suppression_reason) 

1060 if suppression_reason == SuppressionReason.DUPE: 

1061 self.dupe = True 

1062 if envelope.error_count: 

1063 self.deliveries[delivery.name].setdefault(EnvelopeOutcome.ERROR, []) 

1064 self.deliveries[delivery.name][EnvelopeOutcome.ERROR].append(envelope) # type: ignore 

1065 self.failed += 1 

1066 else: 

1067 self.deliveries[delivery.name].setdefault(EnvelopeOutcome.SUPPRESSED, []) 

1068 self.deliveries[delivery.name][EnvelopeOutcome.SUPPRESSED].append(envelope) # type: ignore 

1069 self.suppressed += 1 

1070 

1071 if not envelope: 

1072 delivery_name: str = delivery.name if delivery else "!UNKNOWN!" 

1073 skip_summary: dict[str, Any] = { 

1074 "target_required": delivery.target_required if delivery else "!UNKNOWN!", 

1075 "suppression_reason": str(suppression_reason), 

1076 } 

1077 self.deliveries.setdefault(delivery_name, {}) 

1078 if targets: 

1079 skip_summary["targets"] = targets 

1080 self.deliveries[delivery_name][EnvelopeOutcome.SKIPPED] = skip_summary 

1081 self.skipped += 1 

1082 if suppression_reason and suppression_reason not in self._skip_reasons: 

1083 self._skip_reasons.append(suppression_reason) 

1084 implicit_no_target = suppression_reason == SuppressionReason.NO_TARGET and delivery_name in ( 

1085 self.implicit_only_deliveries 

1086 ) 

1087 if not implicit_no_target and not (suppression_reason and suppression_reason.is_rule): 

1088 # a rule-based skip (occupancy, priority, a delivery condition, snoozed, a 

1089 # disabled transport) is exactly what was configured to happen, and a 

1090 # never-explicitly-asked-for delivery with nothing to target is a routine 

1091 # non-event - neither counts as a miss. Only something that should have gone 

1092 # out but couldn't - because it was actually requested - does. 

1093 self.missed += 1 

1094 if suppression_reason == SuppressionReason.ERROR: 

1095 self.error_count += 1 

1096 self.failed += 1 

1097 

1098 def delivery_outcomes(self) -> dict[DeliveryName, tuple[EnvelopeOutcome, str | None]]: 

1099 """One outcome for each delivery that took part in this notification, with the reason when 

1100 skipped: success if any of its envelopes was delivered, else error if any failed (or the 

1101 delivery itself raised), else skipped - its envelopes suppressed, or none generated.""" 

1102 outcomes: dict[DeliveryName, tuple[EnvelopeOutcome, str | None]] = {} 

1103 for name, results in self.deliveries.items(): 

1104 if results.get(EnvelopeOutcome.SUCCESS): 

1105 outcomes[name] = (EnvelopeOutcome.SUCCESS, None) 

1106 continue 

1107 if results.get(EnvelopeOutcome.ERROR): 

1108 outcomes[name] = (EnvelopeOutcome.ERROR, None) 

1109 continue 

1110 reason: str | None = None 

1111 skipped = results.get(EnvelopeOutcome.SKIPPED) 

1112 if isinstance(skipped, dict): 

1113 reason = skipped.get("suppression_reason") 

1114 suppressed = results.get(EnvelopeOutcome.SUPPRESSED) 

1115 if not reason or reason == "None": 

1116 reason = next((str(e.skip_reason) for e in suppressed or [] if isinstance(e, Envelope) and e.skip_reason), None) 

1117 if reason == SuppressionReason.ERROR: 

1118 outcomes[name] = (EnvelopeOutcome.ERROR, None) 

1119 else: 

1120 outcomes[name] = (EnvelopeOutcome.SKIPPED, reason) 

1121 return outcomes 

1122 

1123 def contents(self, diagnostics: bool = False, **_kwargs: Any) -> dict[str, Any]: 

1124 """ArchiveableObject implementation""" 

1125 minimal = not diagnostics 

1126 object_refs = ["context", "ha_context", "people_registry", "delivery_registry"] 

1127 keys_only = ["enabled_scenarios"] 

1128 debug_only = ["debug_trace"] 

1129 exposed_if_populated = [ 

1130 "_delivery_error", 

1131 "message_html", 

1132 "spoken_message", 

1133 "extra_data", 

1134 "actions", 

1135 "_suppression_reason", 

1136 "delivery_provenance", 

1137 "unknown_names", 

1138 "original_trace", 

1139 ] 

1140 # fine tune dict order to ease the eye-burden when reviewing archived notifications 

1141 preferred_order = [ 

1142 "id", 

1143 "outcome", 

1144 "created", 

1145 "title", 

1146 "message", 

1147 "spoken_message", 

1148 "message_html", 

1149 "priority", 

1150 "target", 

1151 "stats", 

1152 "delivered", 

1153 "failed", 

1154 "suppressed", 

1155 "skipped", 

1156 "missed", 

1157 "fallback", 

1158 "error_count", 

1159 "dupe", 

1160 "force_resend", 

1161 "delivery_selection", 

1162 "delivery_overrides", 

1163 "delivery_selection", 

1164 "selected_deliveries", 

1165 "delivery_provenance", 

1166 "applied_scenario_names", 

1167 "constrain_scenario_names", 

1168 "required_scenario_names", 

1169 "enabled_scenarios", 

1170 "selected_scenario_names", 

1171 "delivery_exceptions", 

1172 "uncategorized_targets", 

1173 "unassigned_targets", 

1174 "unknown_names", 

1175 "extra_data", 

1176 "deliveries", 

1177 "actions", 

1178 "delivery_exceptions", 

1179 "original_context", 

1180 "original_trace", 

1181 "version", 

1182 ] 

1183 # preferred fields 

1184 raw: dict[str, Any] = dict(self.__dict__) 

1185 raw["target"] = self._target.as_dict() if self._target else None 

1186 raw["unassigned_targets"] = self._unassigned_targets() 

1187 raw["stats"] = self._delivery_stats() 

1188 raw["version"] = _VERSION 

1189 raw["title"] = self._title 

1190 raw["outcome"] = self.outcome() 

1191 raw["delivery_provenance"] = self.debug_trace.delivery_provenance 

1192 

1193 result: dict[str, Any] = {} 

1194 if self.ha_context is not None: 

1195 # Context.as_dict() takes no kwargs, so it can't go through sanitize() like everything else 

1196 original_context = dict(self.ha_context.as_dict()) 

1197 user_id = original_context.get("user_id") 

1198 if user_id: 

1199 user_name = self.people_registry.name_for_user_id(user_id) 

1200 if user_name: 

1201 original_context["user"] = user_name 

1202 raw["original_context"] = original_context 

1203 result.update({ 

1204 k: sanitize(raw[k], minimal=minimal, occupancy_only=True, top_level_keys_only=(minimal and k in keys_only)) 

1205 for k in preferred_order 

1206 if k in raw and (k not in exposed_if_populated or raw[k]) 

1207 }) 

1208 

1209 # all the rest not explicitly excluded 

1210 result.update({ 

1211 k: sanitize(v, minimal=minimal, occupancy_only=True) 

1212 for k, v in raw.items() 

1213 if k not in result 

1214 and k not in exposed_if_populated 

1215 and k not in object_refs 

1216 and not k.startswith("_") 

1217 and (not minimal or k not in keys_only) 

1218 and (not minimal or k not in debug_only) 

1219 }) 

1220 # the exposed only if populated fields not already placed by preferred_order 

1221 result.update({ 

1222 k: sanitize(raw[k], minimal=minimal, occupancy_only=True) 

1223 for k in exposed_if_populated 

1224 if k not in result and raw.get(k) 

1225 }) 

1226 return result 

1227 

1228 def _delivery_stats(self) -> dict[str, Any]: 

1229 # delivery_stats: aggregate delivery metrics 

1230 result: dict[str, Any] = {} 

1231 try: 

1232 all_durations: dict[str, float] = {} 

1233 total_ok = 0 

1234 total_all = 0 

1235 for d_name, outcomes in self.deliveries.items(): 

1236 for envelope in outcomes.get(EnvelopeOutcome.SUCCESS, []): 

1237 dur = sum(c.contents().get("elapsed", 0) for c in getattr(envelope, "calls", [])) * 1000 

1238 all_durations[d_name] = dur 

1239 total_ok += 1 

1240 total_all += 1 

1241 for _envelope in outcomes.get(EnvelopeOutcome.ERROR, []): 

1242 all_durations.setdefault(d_name, 0) 

1243 total_all += 1 

1244 if outcomes.get(EnvelopeOutcome.SKIPPED): 

1245 total_all += 1 

1246 if all_durations: 

1247 result = { 

1248 "total_duration_ms": round(sum(all_durations.values()), 1), 

1249 "slowest_delivery": max(all_durations, key=lambda k: all_durations[k]), 

1250 "fastest_delivery": min(all_durations, key=lambda k: all_durations[k]), 

1251 "delivery_success_rate": round(total_ok / total_all, 2) if total_all else 1.0, 

1252 } 

1253 except Exception as e: 

1254 _LOGGER.warning("SUPERNOTIFY delivery_stats computation failed: %s", e) 

1255 return result 

1256 

1257 def base_filename(self) -> str: 

1258 """ArchiveableObject implementation""" 

1259 return f"{self.created.isoformat()[:16].replace(':', '-')}_{self.id}" 

1260 

1261 def delivery_data(self, delivery: Delivery) -> dict[str, Any]: 

1262 if delivery is None: 

1263 return {} 

1264 delivery_override: DeliveryCustomization | None = self.delivery_overrides.get(delivery.name) 

1265 if delivery_override is None: 

1266 delivery_override = self.delivery_overrides.get(delivery.transport.name) 

1267 # a copy: callers (Envelope.__init__) pop message/title out of this, and the same 

1268 # override can now be shared by more than one delivery on the same transport 

1269 return dict(delivery_override.data) if delivery_override and delivery_override.data else {} 

1270 

1271 @property 

1272 def delivered_envelopes(self) -> list[Envelope]: 

1273 result: list[Envelope] = [] 

1274 for delivery_result in self.deliveries.values(): 

1275 result.extend(cast("list[Envelope]", delivery_result.get(EnvelopeOutcome.SUCCESS, []))) 

1276 return result 

1277 

1278 @property 

1279 def undelivered_envelopes(self) -> list[Envelope]: 

1280 result: list[Envelope] = [] 

1281 for delivery_result in self.deliveries.values(): 

1282 result.extend(cast("list[Envelope]", delivery_result.get(EnvelopeOutcome.SUPPRESSED, []))) 

1283 result.extend(cast("list[Envelope]", delivery_result.get(EnvelopeOutcome.ERROR, []))) 

1284 return result 

1285 

1286 async def select_scenarios(self) -> list[str]: 

1287 return [s.name for s in self.context.scenario_registry.scenarios.values() if s.evaluate(self.condition_variables)] 

1288 

1289 def _filter_resolve_select_targets( 

1290 self, computed_target: Target, delivery: Delivery, stages: tuple[str, str, str, str] 

1291 ) -> Target: 

1292 """Snooze-filter, resolve indirect/scenario targets, then apply delivery target selection.""" 

1293 # before the people are resolved to their own targets, and again after selection has 

1294 # resolved areas, floors, labels and groups to entities 

1295 computed_target = self._without_excluded(computed_target) 

1296 computed_target = self.context.snoozer.filter_recipients( 

1297 computed_target, 

1298 self.priority, 

1299 delivery, 

1300 self.media.get(ATTR_MEDIA_CAMERA_ENTITY_ID), 

1301 tags=self.snooze_tags(), 

1302 ) 

1303 self.debug_trace.record_target(delivery.name, stages[0], computed_target) 

1304 for indirect_target in self.resolve_indirect_targets(computed_target, delivery): 

1305 computed_target += indirect_target 

1306 self.debug_trace.record_target(delivery.name, stages[1], computed_target) 

1307 computed_target += self.resolve_scenario_targets(delivery) 

1308 computed_target = self._short_circuit_own_entities(computed_target, delivery) 

1309 self.debug_trace.record_target(delivery.name, stages[2], computed_target) 

1310 computed_target = self._without_excluded(delivery.select_targets(computed_target)) 

1311 self.debug_trace.record_target(delivery.name, stages[3], computed_target) 

1312 return computed_target 

1313 

1314 def _without_excluded(self, target: Target) -> Target: 

1315 if not self._exclude: 

1316 return target 

1317 if self._excluded is None: 

1318 entity_ids, device_ids = self.context.hass_api.resolve_target_exclusion(self._exclude) 

1319 self._excluded = Target({ 

1320 ATTR_ENTITY_ID: sorted(entity_ids), 

1321 ATTR_PERSON_ID: sorted(entity_ids), 

1322 ATTR_DEVICE_ID: sorted(device_ids), 

1323 }) 

1324 return target - self._excluded 

1325 

1326 def generate_targets(self, delivery: Delivery, target_override: DeliveryTargetOverride | None = None) -> list[Target]: 

1327 

1328 if delivery.target_required == TargetRequired.NEVER: 

1329 # don't waste time computing targets for deliveries that don't need them 

1330 return [Target(None, target_data=delivery.data)] 

1331 

1332 computed_target: Target 

1333 

1334 if delivery.target_usage == TARGET_USE_FIXED: 

1335 if delivery.target: 

1336 computed_target = delivery.target.safe_copy() 

1337 self.debug_trace.record_target(delivery.name, "100_delivery_default_fixed", computed_target) 

1338 else: 

1339 computed_target = Target(None, target_data=delivery.data) 

1340 self.debug_trace.record_target(delivery.name, "101_delivery_default_fixed_empty", computed_target) 

1341 elif target_override is not None and target_override.fixed: 

1342 # personal deliveries 

1343 computed_target = Target(target_override.fixed) 

1344 self.debug_trace.record_target(delivery.name, "102_delivery_default_fixed", computed_target) 

1345 

1346 elif not self._target: 

1347 # Unless there are explicit targets, include everyone on the people registry 

1348 computed_target = self.default_person_ids(delivery) 

1349 self.debug_trace.record_target(delivery.name, "201_no_action_target", computed_target) 

1350 else: 

1351 computed_target = self._target.safe_copy() 

1352 if target_override is not None: 

1353 if target_override.include: 

1354 computed_target += Target(target_override.include) 

1355 if target_override.exclude: 

1356 computed_target -= Target(target_override.exclude) 

1357 self.debug_trace.record_target(delivery.name, "202_action_target", computed_target) 

1358 if self._apply_delivery_defaults and INCLUSION_DEFAULT in delivery.inclusion: 

1359 computed_target += self.default_person_ids(delivery) 

1360 self.debug_trace.record_target(delivery.name, "203_action_target_plus_defaults", computed_target) 

1361 

1362 # 1st round of filtering for snooze and resolving people->direct targets 

1363 computed_target = self._filter_resolve_select_targets( 

1364 computed_target, 

1365 delivery, 

1366 ("300_post_snooze", "310_resolve_indirect", "320_resolved_scenario_targets", "330_delivery_selection"), 

1367 ) 

1368 primary_count = len(computed_target) 

1369 

1370 if delivery.target_usage == TARGET_USE_ON_NO_DELIVERY_TARGETS: 

1371 # person_ids survive selection, but aren't targets to deliver to in their own right 

1372 if not computed_target.has_resolved_target() and delivery.target: 

1373 computed_target += delivery.target 

1374 self.debug_trace.record_target(delivery.name, "400_delivery_default_no_delivery_targets", computed_target) 

1375 elif delivery.target_usage == TARGET_USE_ON_NO_ACTION_TARGETS: 

1376 if not self._target and delivery.target: 

1377 computed_target += delivery.target 

1378 self.debug_trace.record_target(delivery.name, "401_delivery_default_no_action_targets", computed_target) 

1379 elif delivery.target_usage == TARGET_USE_MERGE_ON_DELIVERY_TARGETS: 

1380 # merge in the delivery defaults if there's a target defined in action call 

1381 if computed_target.has_resolved_target() and delivery.target: 

1382 computed_target += delivery.target 

1383 self.debug_trace.record_target(delivery.name, "402_delivery_merge_on_delivery_targets", computed_target) 

1384 elif delivery.target_usage == TARGET_USE_MERGE_ALWAYS: 

1385 # merge in the delivery defaults even if there's not a target defined in action call 

1386 if delivery.target: 

1387 computed_target += delivery.target 

1388 self.debug_trace.record_target(delivery.name, "403_delivery_merge_always_targets", computed_target) 

1389 elif delivery.target_usage == TARGET_USE_FIXED: 

1390 _LOGGER.debug("SUPERNOTIFY Fixed target on delivery %s", delivery.name) 

1391 self.debug_trace.record_target(delivery.name, "404_fixed_target", computed_target) 

1392 else: 

1393 self.debug_trace.record_target(delivery.name, "405_no_target_usage_match", computed_target) 

1394 _LOGGER.debug("SUPERNOTIFY No useful target definition for delivery %s", delivery.name) 

1395 

1396 if len(computed_target) > primary_count: 

1397 _LOGGER.debug( 

1398 "SUPERNOTIFY Delivery config added %s targets for %s", len(computed_target) - primary_count, delivery.name 

1399 ) 

1400 

1401 # 2nd round of filtering for snooze and resolving people->direct targets after delivery target applied 

1402 computed_target = self._filter_resolve_select_targets( 

1403 computed_target, 

1404 delivery, 

1405 ("501_post_snooze", "502_resolve_indirect", "503_resolved_scenario_targets", "504_delivery_selection"), 

1406 ) 

1407 

1408 # If the action call explicitly specified a target for this delivery, it takes 

1409 # precedence over all resolved/merged targets above. 

1410 delivery_override: DeliveryCustomization | None = self.delivery_overrides.get(delivery.name) 

1411 if delivery_override is None: 

1412 # if override doesn't use a valid delivery name, try a transport name instead 

1413 delivery_override = self.delivery_overrides.get(delivery.transport.name) 

1414 if delivery_override and delivery_override.target and delivery_override.target.has_targets(): 

1415 # exclusively scoped to this one delivery, so safe to claim an unqualified value 

1416 override_target = delivery.reclassify_unqualified_target(delivery_override.target) 

1417 self._record_uncategorized(delivery, override_target) 

1418 # handle and resolve indirect targets, like person->mobile device or email 

1419 for indirect_target in self.resolve_indirect_targets(override_target, delivery): 

1420 override_target += indirect_target 

1421 computed_target = delivery.select_targets(self._short_circuit_own_entities(override_target, delivery)) 

1422 self.debug_trace.record_target(delivery.name, "600_delivery_override_target", computed_target) 

1423 

1424 split_targets: list[Target] = computed_target.split_by_target_data() 

1425 self.debug_trace.record_target(delivery.name, "610_delivery_split_targets", split_targets) 

1426 

1427 direct_targets: list[Target] = [t.direct() for t in split_targets] 

1428 self.debug_trace.record_target(delivery.name, "620_narrow_to_direct", direct_targets) 

1429 

1430 if self._is_fallback_only(delivery): 

1431 direct_targets = [t - self._already_selected for t in direct_targets] 

1432 self.debug_trace.record_target(delivery.name, "630_fallback_excludes_already_selected", direct_targets) 

1433 for direct_target in direct_targets: 

1434 self._already_selected += direct_target 

1435 # after the uniqueness bookkeeping above, so that only ever deals in what's delivered to 

1436 self._attach_person_ids(delivery, computed_target.person_ids, direct_targets) 

1437 self.debug_trace.record_target(delivery.name, "999_final_cut", direct_targets) 

1438 return direct_targets 

1439 

1440 def _attach_person_ids(self, delivery: Delivery, person_ids: list[str], envelope_targets: list[Target]) -> None: 

1441 """Record on each envelope target which of the notification's recipients it reaches 

1442 

1443 A recipient is only added to the envelope targets holding one of their own targets 

1444 for this delivery, so one with nothing this delivery can send to, or whose addresses 

1445 were all already sent in an earlier envelope, isn't counted as notified by this one. 

1446 """ 

1447 for person_id in person_ids: 

1448 recipient: Recipient | None = self.people_registry.people.get(person_id) 

1449 if recipient is None: 

1450 continue 

1451 recipient_targets: set[str] = set(recipient.target(delivery.name).resolved_targets()) 

1452 for envelope_target in envelope_targets: 

1453 if recipient_targets.intersection(envelope_target.resolved_targets()): 

1454 envelope_target.extend(ATTR_PERSON_ID, person_id) 

1455 self._reached_person_ids.add(person_id) 

1456 

1457 def resolve_scenario_targets(self, delivery: Delivery) -> Target: 

1458 resolved: Target = Target() 

1459 for scenario in self.enabled_scenarios.values(): 

1460 customization: DeliveryCustomization | None = scenario.delivery_customization(delivery.name) 

1461 if customization and customization.target and customization.target.has_targets(): 

1462 # exclusively scoped to this one delivery, so safe to claim an unqualified value 

1463 reclassified = delivery.reclassify_unqualified_target(customization.target) 

1464 self._record_uncategorized(delivery, reclassified) 

1465 resolved += reclassified 

1466 return resolved 

1467 

1468 def _record_uncategorized(self, delivery: Delivery, target: Target) -> None: 

1469 """Track a delivery-scoped target's leftover uncategorised values, once 

1470 

1471 `Delivery.reclassify_unqualified_target()` has had its one chance to place them (it 

1472 already logs a warning) - collected here so the caller (`SupernotifyEngine. 

1473 async_send_message()`) can raise a single `UncategorizedTargetError` for the whole 

1474 notification, once delivery has fully finished and everything deliverable is sent. 

1475 """ 

1476 if target.has_unknown_targets(): 

1477 self.uncategorized_targets.setdefault(delivery.name, []) 

1478 self.uncategorized_targets[delivery.name].extend(target.custom_ids(Target.UNKNOWN_CUSTOM_CATEGORY)) 

1479 

1480 def _unassigned_targets(self) -> dict[str, list[str]]: 

1481 """Archive-only diagnostic: requested target values with a recognisable category 

1482 

1483 (entity_id, email, phone, mobile_app_id, device_id, or an explicit custom category) 

1484 that never ended up in any envelope across the whole notification - as opposed to 

1485 `uncategorized_targets`, which tracks values with no recognisable shape at all - and 

1486 recipients asked for whose own targets didn't reach any delivery. Grouped by category, like `uncategorized_targets`, so a gap is easy to place when 

1487 debugging. Computed here rather than tracked as an instance attribute, since it's 

1488 only ever needed for the archive. 

1489 """ 

1490 if not self._target: 

1491 return {} 

1492 result: dict[str, list[str]] = {} 

1493 for category, targets in self._target.direct().targets.items(): 

1494 if category == Target.UNKNOWN_CUSTOM_CATEGORY: 

1495 continue 

1496 unassigned = [t for t in targets if t not in self._already_selected.targets.get(category, [])] 

1497 if unassigned: 

1498 result[category] = unassigned 

1499 # a recipient asked for, that no delivery had a target of theirs for 

1500 if unreached := [p for p in self._target.person_ids if p not in self._reached_person_ids]: 

1501 result[ATTR_PERSON_ID] = unreached 

1502 return result 

1503 

1504 def all_recipients(self) -> list[Recipient]: 

1505 recipients: list[Recipient] = [] 

1506 if self._target: 

1507 # explicit targets given 

1508 recipients.extend( 

1509 self.people_registry.people[pers_ent_id] 

1510 for pers_ent_id in self._target.person_ids 

1511 if pers_ent_id in self.people_registry.people and self.people_registry.people[pers_ent_id].enabled 

1512 ) 

1513 elif not self._no_default_recipients: 

1514 # default to all known recipients 

1515 recipients = self.people_registry.enabled_recipients() 

1516 return recipients 

1517 

1518 def default_person_ids(self, delivery: Delivery) -> Target: 

1519 # If target not specified on service call or delivery, then default to std list of recipients 

1520 if self._no_default_recipients: 

1521 return Target() 

1522 people: list[Recipient] = self.people_registry.filter_recipients_by_occupancy(delivery.occupancy) 

1523 return Target({ 

1524 ATTR_PERSON_ID: [p.entity_id for p in people if p.entity_id and delivery.name not in p.disabling_delivery_names()] 

1525 }) 

1526 

1527 def resolve_indirect_targets(self, target: Target, delivery: Delivery) -> list[Target]: 

1528 # enrich data selected in configuration for this delivery, from direct target definition or attrs like email or phone 

1529 resolved: Target = Target() 

1530 additional: list[Target] = [] 

1531 

1532 for person_id in target.person_ids: 

1533 recipient: Recipient | None = self.people_registry.people.get(person_id) 

1534 if recipient and recipient.enabled: 

1535 # the person's own mobile devices only appear now, after the snooze filter has run on 

1536 # the action's targets, so a snoozed device is dropped here - the person stays in the 

1537 # target, as the link back to who is being notified 

1538 recipient_target = self.context.snoozer.filter_mobile_app_ids( 

1539 recipient.target(delivery.name), self.priority, delivery 

1540 ) 

1541 if recipient_target.target_specific_data: 

1542 additional.append(recipient_target) 

1543 else: 

1544 resolved += recipient_target 

1545 else: 

1546 _LOGGER.debug("SUPERNOTIFY Skipping recipient %s with enabled switched off", person_id) 

1547 

1548 return [resolved, *additional] 

1549 

1550 def generate_envelopes(self, delivery: Delivery, targets: list[Target]) -> list[Envelope]: 

1551 # now the list of recipients determined, resolve this to target addresses or entities 

1552 

1553 envelopes: list[Envelope] = [] 

1554 target_required = self._target_required(delivery) 

1555 for target in targets: 

1556 # a target is always generated, even if there are no recipients 

1557 if target.has_resolved_target() or target_required != TargetRequired.ALWAYS: 

1558 envelope_data = {} 

1559 

1560 # least priority - delivery derived data 

1561 envelope_data.update(delivery.data) 

1562 # next least priority - target derived data 

1563 if target.target_data: 

1564 envelope_data.update(target.target_data) 

1565 

1566 # scenario applied at cross-delivery level in apply_enabled_scenarios 

1567 for scenario in self.enabled_scenarios.values(): 

1568 customization: DeliveryCustomization | None = scenario.delivery_customization(delivery.name) 

1569 if customization and customization.data: 

1570 envelope_data.update(customization.data) 

1571 

1572 # apply data from action call last to prioritize it 

1573 envelope_data.update(self.extra_data) # action call data 

1574 

1575 envelopes.append( 

1576 Envelope(delivery, self, target, envelope_data, context=self.context, ha_context=self.ha_context) 

1577 ) 

1578 

1579 return envelopes