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
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
1from __future__ import annotations
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
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
16from custom_components.supernotify.delivery import DeliveryProvenance
17from custom_components.supernotify.schema import SelectionRank
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
72if TYPE_CHECKING:
73 from homeassistant.core import Context as HAContext
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 )
84_LOGGER = logging.getLogger(__name__)
86_VERSION: str = "unknown"
89def set_version(version: str) -> None:
90 """Called once from async_setup_entry with the integration's manifest version.
92 Avoids a blocking manifest.json read on the event loop at import time.
93 """
94 global _VERSION
95 _VERSION = version
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
101type DeliveryName = str
104@dataclass
105class DeliveryTargetOverride:
106 fixed: list[str]
107 include: list[str]
108 exclude: list[str]
110 def as_dict(self, **_kwargs: Any) -> dict[str, Any]:
111 return {"fixed": self.fixed, "include": self.include, "exclude": self.exclude}
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.
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]}
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 {}
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))
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
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] = []
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 }
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
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] = {}
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)
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))
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()
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.
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
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
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
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()
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()
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}
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 }
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.
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
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
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.
442 Defined here rather than in models/Target since requires access to registries.
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()
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
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)
486 if changed:
487 return updated
488 return target
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
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)
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
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
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
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
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
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()
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))
589 scenario_enable_deliveries = list(dict.fromkeys(scenario_enable_deliveries))
590 scenario_disable_deliveries = list(dict.fromkeys(scenario_disable_deliveries))
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")
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)
640 override_enable_deliveries: list[str] = []
641 override_disable_deliveries: list[str] = []
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")
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)
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)
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 }
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)
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)
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
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)
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 )
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
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))
781 _LOGGER.debug("SUPERNOTIFY Scheduling %s immediate deliveries", len(deferred_deliveries))
782 await self._schedule_deliveries(immediate_deliveries)
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()
796 _LOGGER.debug("SUPERNOTIFY Scheduling %s deferred deliveries", len(deferred_deliveries))
797 await self._schedule_deliveries(deferred_deliveries)
798 await self._fall_back()
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
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
821 return self.delivered > 0
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
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)
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
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
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
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)
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
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.
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
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 )
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
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
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)
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)
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
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
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
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
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 })
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
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
1257 def base_filename(self) -> str:
1258 """ArchiveableObject implementation"""
1259 return f"{self.created.isoformat()[:16].replace(':', '-')}_{self.id}"
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 {}
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
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
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)]
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
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
1326 def generate_targets(self, delivery: Delivery, target_override: DeliveryTargetOverride | None = None) -> list[Target]:
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)]
1332 computed_target: Target
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)
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)
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)
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)
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 )
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 )
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)
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)
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)
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
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
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)
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
1468 def _record_uncategorized(self, delivery: Delivery, target: Target) -> None:
1469 """Track a delivery-scoped target's leftover uncategorised values, once
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))
1480 def _unassigned_targets(self) -> dict[str, list[str]]:
1481 """Archive-only diagnostic: requested target values with a recognisable category
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
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
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 })
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] = []
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)
1548 return [resolved, *additional]
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
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 = {}
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)
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)
1572 # apply data from action call last to prioritize it
1573 envelope_data.update(self.extra_data) # action call data
1575 envelopes.append(
1576 Envelope(delivery, self, target, envelope_data, context=self.context, ha_context=self.ha_context)
1577 )
1579 return envelopes