Coverage for custom_components/supernotify/notification.py: 11%
439 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-01-07 15:35 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-01-07 15:35 +0000
1import datetime as dt
2import logging
3import uuid
4from traceback import format_exception
5from typing import TYPE_CHECKING, Any, cast
7import voluptuous as vol
8from homeassistant.components.notify.const import ATTR_DATA
9from voluptuous import humanize
11from . import (
12 ACTION_DATA_SCHEMA,
13 ATTR_ACTION_GROUPS,
14 ATTR_ACTIONS,
15 ATTR_DEBUG,
16 ATTR_DELIVERY,
17 ATTR_DELIVERY_SELECTION,
18 ATTR_MEDIA,
19 ATTR_MEDIA_CLIP_URL,
20 ATTR_MEDIA_SNAPSHOT_URL,
21 ATTR_MESSAGE_HTML,
22 ATTR_PERSON_ID,
23 ATTR_PRIORITY,
24 ATTR_RECIPIENTS,
25 ATTR_SCENARIOS_APPLY,
26 ATTR_SCENARIOS_CONSTRAIN,
27 ATTR_SCENARIOS_REQUIRE,
28 DELIVERY_SELECTION_EXPLICIT,
29 DELIVERY_SELECTION_FIXED,
30 DELIVERY_SELECTION_IMPLICIT,
31 OPTION_UNIQUE_TARGETS,
32 PRIORITY_MEDIUM,
33 PRIORITY_VALUES,
34 STRICT_ACTION_DATA_SCHEMA,
35 TARGET_USE_FIXED,
36 TARGET_USE_MERGE_ALWAYS,
37 TARGET_USE_MERGE_ON_DELIVERY_TARGETS,
38 TARGET_USE_ON_NO_ACTION_TARGETS,
39 TARGET_USE_ON_NO_DELIVERY_TARGETS,
40 SelectionRank,
41)
42from .archive import ArchivableObject
43from .common import ensure_list, nullable_ensure_list, sanitize
44from .context import Context
45from .delivery import Delivery, DeliveryRegistry
46from .envelope import Envelope
47from .model import ConditionVariables, DebugTrace, DeliveryCustomization, SuppressionReason, Target, TargetRequired
48from .people import Recipient
50if TYPE_CHECKING:
51 from .people import PeopleRegistry
52 from .scenario import Scenario
53 from .transport import (
54 Transport,
55 )
57_LOGGER = logging.getLogger(__name__)
59# Deliveries mapping keys for debug / archive
60KEY_DELIVERED = "delivered"
61KEY_SUPPRESSED = "suppressed"
62KEY_FAILED = "failed"
63KEY_SKIPPED = "skipped"
65type t_delivery_name = str
66type t_outcome = str
69class Notification(ArchivableObject):
70 def __init__(
71 self,
72 context: Context,
73 message: str | None = None,
74 title: str | None = None,
75 target: list[str] | str | None = None,
76 action_data: dict[str, Any] | None = None,
77 ) -> None:
78 self.created: dt.datetime = dt.datetime.now(tz=dt.UTC)
79 self.debug_trace: DebugTrace = DebugTrace(message=message, title=title, data=action_data, target=target)
80 self.message: str | None = message
81 self.context: Context = context
82 self.people_registry: PeopleRegistry = context.people_registry
83 self.delivery_registry: DeliveryRegistry = context.delivery_registry
84 action_data = action_data or {}
85 self._target: Target | None = Target(target) if target else None
86 self._already_selected: Target = Target()
87 self._title: str | None = title
88 self.id = str(uuid.uuid1())
89 self.delivered: int = 0
90 self.error_count: int = 0
91 self.skipped: int = 0
92 self.failed: int = 0
93 self.suppressed: int = 0
94 self.dupe: bool = False
95 self.deliveries: dict[t_delivery_name, dict[t_outcome, list[str] | list[Envelope] | dict[str, Any]]] = {}
96 self._skip_reasons: list[SuppressionReason] = []
98 self.validate_action_data(action_data)
99 # for compatibility with other notify calls, pass thru surplus data to underlying delivery transports
100 self.extra_data: dict[str, Any] = {
101 k: v for k, v in action_data.items() if k not in STRICT_ACTION_DATA_SCHEMA(action_data)
102 }
103 action_data = {k: v for k, v in action_data.items() if k not in self.extra_data}
105 self.priority: str = action_data.get(ATTR_PRIORITY, PRIORITY_MEDIUM)
106 self.message_html: str | None = action_data.get(ATTR_MESSAGE_HTML)
107 self.required_scenario_names: list[str] = ensure_list(action_data.get(ATTR_SCENARIOS_REQUIRE))
108 self.applied_scenario_names: list[str] = ensure_list(action_data.get(ATTR_SCENARIOS_APPLY))
109 self.constrain_scenario_names: list[str] = ensure_list(action_data.get(ATTR_SCENARIOS_CONSTRAIN))
110 self.delivery_selection: str | None = action_data.get(ATTR_DELIVERY_SELECTION)
111 self.delivery_overrides: dict[str, DeliveryCustomization] = {}
113 delivery_data = action_data.get(ATTR_DELIVERY)
114 if isinstance(delivery_data, list):
115 # a bare list of deliveries implies intent to restrict
116 _LOGGER.debug("SUPERNOTIFY defaulting delivery selection as explicit for list %s", delivery_data)
117 if self.delivery_selection is None:
118 self.delivery_selection = DELIVERY_SELECTION_EXPLICIT
119 self.delivery_overrides = {k: DeliveryCustomization({}) for k in action_data.get(ATTR_DELIVERY, [])}
120 elif isinstance(delivery_data, str) and delivery_data:
121 # a bare list of deliveries implies intent to restrict
122 _LOGGER.debug("SUPERNOTIFY defaulting delivery selection as explicit for single %s", delivery_data)
123 if self.delivery_selection is None:
124 self.delivery_selection = DELIVERY_SELECTION_EXPLICIT
125 self.delivery_overrides = {delivery_data: DeliveryCustomization({})}
126 elif isinstance(delivery_data, dict):
127 # whereas a dict may be used to tune or restrict
128 if self.delivery_selection is None:
129 self.delivery_selection = DELIVERY_SELECTION_IMPLICIT
130 _LOGGER.debug("SUPERNOTIFY defaulting delivery selection as implicit for mapping %s", delivery_data)
131 self.delivery_overrides = {k: DeliveryCustomization(v) for k, v in action_data.get(ATTR_DELIVERY, {}).items()}
132 elif delivery_data:
133 _LOGGER.warning("SUPERNOTIFY Unable to interpret delivery data %s", delivery_data)
134 if self.delivery_selection is None:
135 self.delivery_selection = DELIVERY_SELECTION_IMPLICIT
136 else:
137 if self.delivery_selection is None:
138 self.delivery_selection = DELIVERY_SELECTION_IMPLICIT
140 self.action_groups: list[str] | None = nullable_ensure_list(action_data.get(ATTR_ACTION_GROUPS))
141 self.recipients_override: list[str] | None = nullable_ensure_list(action_data.get(ATTR_RECIPIENTS))
142 self.extra_data.update(action_data.get(ATTR_DATA, {}))
143 self.media: dict[str, Any] = action_data.get(ATTR_MEDIA) or {}
144 self.debug: bool = action_data.get(ATTR_DEBUG, False)
145 self.actions: list[dict[str, Any]] = ensure_list(action_data.get(ATTR_ACTIONS))
147 self.selected_deliveries: dict[str, dict[str, Any]] = {}
148 self.enabled_scenarios: dict[str, Scenario] = {}
149 self.selected_scenario_names: list[str] = []
150 self._suppression_reason: SuppressionReason | None = None
151 self._delivery_error: list[str] | None = None
152 self.condition_variables: ConditionVariables
154 async def initialize(self) -> None:
155 """Async post-construction initialization"""
156 self.occupancy: dict[str, list[Recipient]] = self.people_registry.determine_occupancy()
157 self.condition_variables = ConditionVariables(
158 self.applied_scenario_names,
159 self.required_scenario_names,
160 self.constrain_scenario_names,
161 self.priority,
162 self.occupancy,
163 self.message,
164 self._title,
165 ) # requires occupancy first
167 enabled_scenario_names: list[str] = list(self.applied_scenario_names) or []
168 self.selected_scenario_names = await self.select_scenarios()
169 enabled_scenario_names.extend(self.selected_scenario_names)
170 if self.constrain_scenario_names:
171 enabled_scenario_names = [
172 s for s in enabled_scenario_names if (s in self.constrain_scenario_names or s in self.applied_scenario_names)
173 ]
174 if self.required_scenario_names and not any(s in enabled_scenario_names for s in self.required_scenario_names):
175 _LOGGER.info("SUPERNOTIFY suppressing notification, no required scenarios enabled")
176 self.selected_deliveries = {}
177 self.suppress(SuppressionReason.NO_SCENARIO)
178 else:
179 for s in enabled_scenario_names:
180 scenario_obj = self.context.scenario_registry.scenarios.get(s)
181 if scenario_obj is not None:
182 self.enabled_scenarios[s] = scenario_obj
184 self.selected_deliveries = self.select_deliveries()
185 if self.context.snoozer.is_global_snooze(self.priority):
186 self.suppress(SuppressionReason.SNOOZED)
187 self.apply_enabled_scenarios()
189 if not self.media:
190 self.media = self.media_requirements(self.extra_data)
192 def media_requirements(self, data: dict[str, Any]) -> dict[str, Any]:
193 """If no media defined, look for iOS / Android actions that have media defined
195 Example is the Frigate blueprint, which generates `image`, `video` etc
196 in the `data` section, that can also be used for email attachments
197 """
198 media_dict = {}
199 if not data:
200 return {}
201 if data.get("image"):
202 media_dict[ATTR_MEDIA_SNAPSHOT_URL] = data.get("image")
203 if data.get("video"):
204 media_dict[ATTR_MEDIA_CLIP_URL] = data.get("video")
205 if data.get("attachment", {}).get("url"):
206 url = data["attachment"]["url"]
207 if url and url.endswith(".mp4") and not media_dict.get(ATTR_MEDIA_CLIP_URL):
208 media_dict[ATTR_MEDIA_CLIP_URL] = url
209 elif (
210 url
211 and (url.endswith(".jpg") or url.endswith(".jpeg") or url.endswith(".png"))
212 and not media_dict.get(ATTR_MEDIA_SNAPSHOT_URL)
213 ):
214 media_dict[ATTR_MEDIA_SNAPSHOT_URL] = url
215 return media_dict
217 def validate_action_data(self, action_data: dict[str, Any]) -> None:
218 if action_data.get(ATTR_PRIORITY) and action_data.get(ATTR_PRIORITY) not in PRIORITY_VALUES:
219 _LOGGER.info("SUPERNOTIFY custom priority %s", action_data.get(ATTR_PRIORITY))
220 try:
221 humanize.validate_with_humanized_errors(action_data, ACTION_DATA_SCHEMA)
222 except vol.Invalid as e:
223 _LOGGER.warning("SUPERNOTIFY invalid service data %s: %s", action_data, e)
224 raise
226 def apply_enabled_scenarios(self) -> None:
227 """Set media and action_groups from scenario if defined, first come first applied"""
228 action_groups: list[str] = []
229 for scenario in self.enabled_scenarios.values():
230 if scenario.media:
231 if self.media:
232 self.media.update(scenario.media)
233 else:
234 self.media = scenario.media
235 if scenario.action_groups:
236 action_groups.extend(ag for ag in scenario.action_groups if ag not in action_groups)
237 # self.action_groups only accessed from inside Envelope
238 if self.action_groups:
239 self.action_groups.extend(action_groups)
240 else:
241 self.action_groups = action_groups
243 def select_deliveries(self) -> dict[str, dict[str, Any]]:
244 scenario_enable_deliveries: list[str] = []
245 scenario_disable_deliveries: list[str] = []
246 default_enable_deliveries: list[str] = []
247 recipients_enable_deliveries: list[str] = []
249 if self.delivery_selection != DELIVERY_SELECTION_FIXED:
250 for scenario in self.enabled_scenarios.values():
251 scenario_enable_deliveries.extend(scenario.enabling_deliveries())
252 for scenario in self.enabled_scenarios.values():
253 scenario_disable_deliveries.extend(scenario.disabling_deliveries())
255 scenario_enable_deliveries = list(set(scenario_enable_deliveries))
256 scenario_disable_deliveries = list(set(scenario_disable_deliveries))
258 for recipient in self.all_recipients():
259 recipients_enable_deliveries.extend(recipient.enabling_delivery_names())
260 if self.delivery_selection == DELIVERY_SELECTION_IMPLICIT:
261 # all deliveries with SELECTION_DEFAULT in CONF_SELECTION
262 default_enable_deliveries = [d.name for d in self.context.delivery_registry.implicit_deliveries]
264 self.debug_trace.record_delivery_selection("scenario_enable_deliveries", scenario_enable_deliveries)
265 self.debug_trace.record_delivery_selection("scenario_disable_deliveries", scenario_disable_deliveries)
266 self.debug_trace.record_delivery_selection("default_enable_deliveries", default_enable_deliveries)
267 self.debug_trace.record_delivery_selection("recipient_enable_deliveries", recipients_enable_deliveries)
269 override_enable_deliveries: list[str] = []
270 override_disable_deliveries: list[str] = []
272 # apply the deliveries defined in the notification action call
273 for delivery, delivery_override in self.delivery_overrides.items():
274 if (
275 (delivery_override is None or delivery_override.enabled is True)
276 and delivery in self.context.delivery_registry.enabled_deliveries
277 ) or (
278 (delivery_override is not None and delivery_override.enabled is True)
279 and delivery in self.context.delivery_registry.disabled_deliveries
280 ):
281 override_enable_deliveries.append(delivery)
282 elif delivery_override is not None and delivery_override.enabled is False:
283 override_disable_deliveries.append(delivery)
285 # if self.delivery_selection != DELIVERY_SELECTION_FIXED:
286 # scenario_disable_deliveries = [
287 # d.name
288 # for d in self.context.delivery_registry.deliveries.values()
289 # if d.selection == [SELECTION_BY_SCENARIO]
290 # and d.name not in scenario_enable_deliveries
291 # and (d.name not in override_enable_deliveries or self.delivery_selection != DELIVERY_SELECTION_EXPLICIT)
292 # ]
293 all_global_enabled: list[str] = list(
294 set(scenario_enable_deliveries + default_enable_deliveries + override_enable_deliveries)
295 )
296 all_enabled: list[str] = all_global_enabled + recipients_enable_deliveries
297 all_disabled: list[str] = scenario_disable_deliveries + override_disable_deliveries
298 override_enabled: list[str] = list(set(scenario_enable_deliveries + override_enable_deliveries))
299 self.debug_trace.record_delivery_selection("override_disable_deliveries", override_disable_deliveries)
300 self.debug_trace.record_delivery_selection("override_enable_deliveries", override_enable_deliveries)
302 unsorted_maybe_objs: list[Delivery | None] = [
303 self.delivery_registry.deliveries.get(d) for d in all_enabled if d not in all_disabled
304 ]
305 unsorted_objs: list[Delivery] = [
306 d for d in unsorted_maybe_objs if d is not None and (d.enabled or d.name in override_enabled)
307 ]
308 first: list[str] = [d.name for d in unsorted_objs if d.selection_rank == SelectionRank.FIRST]
309 anywhere: list[str] = [d.name for d in unsorted_objs if d.selection_rank == SelectionRank.ANY]
310 last: list[str] = [d.name for d in unsorted_objs if d.selection_rank == SelectionRank.LAST]
311 selected = first + anywhere + last
312 self.debug_trace.record_delivery_selection("ranked", selected)
314 # TODO: clean up this ugly logic, reorganize delivery around people
315 results: dict[str, dict[str, Any]] = {d: {} for d in selected}
316 personal_deliveries = [d for d in selected if d not in all_global_enabled and d in recipients_enable_deliveries]
317 for personal_delivery in personal_deliveries:
318 results[personal_delivery].setdefault("recipients", [])
319 for recipient in self.all_recipients():
320 if personal_delivery in recipient.enabling_delivery_names():
321 results[personal_delivery]["recipients"].append(recipient.entity_id)
322 return results
324 def suppress(self, reason: SuppressionReason) -> None:
325 self._suppression_reason = reason
326 if reason not in self._skip_reasons:
327 self._skip_reasons.append(reason)
328 _LOGGER.info(f"SUPERNOTIFY Suppressing notification, reason:{reason}, id:{self.id}")
330 async def deliver(self) -> bool:
331 _LOGGER.debug(
332 "Message: %s, notification: %s, deliveries: %s",
333 self.message,
334 self.id,
335 self.selected_deliveries,
336 )
338 for delivery_name, details in self.selected_deliveries.items():
339 self.deliveries[delivery_name] = {}
340 delivery = self.context.delivery_registry.deliveries.get(delivery_name)
341 if self._suppression_reason is not None:
342 _LOGGER.info("SUPERNOTIFY Suppressing globally silenced/snoozed notification (%s)", self.id)
343 self.record_result(delivery, suppression_reason=SuppressionReason.SNOOZED)
344 elif delivery:
345 await self.call_transport(delivery, recipients=details.get("recipients"))
346 else:
347 _LOGGER.error(f"SUPERNOTIFY Unexpected missing delivery {delivery_name}")
349 if self.delivered == 0 and not self._suppression_reason:
350 if self.failed == 0 and not self.dupe:
351 for delivery in self.context.delivery_registry.fallback_by_default_deliveries:
352 if delivery.name not in self.selected_deliveries:
353 await self.call_transport(delivery)
355 if self.failed > 0:
356 for delivery in self.context.delivery_registry.fallback_on_error_deliveries:
357 if delivery.name not in self.selected_deliveries:
358 await self.call_transport(delivery)
360 return self.delivered > 0
362 async def call_transport(self, delivery: Delivery, recipients: list[str] | None = None) -> None:
363 try:
364 transport: Transport = delivery.transport
365 if not transport.enabled:
366 self.record_result(delivery, suppression_reason=SuppressionReason.TRANSPORT_DISABLED)
367 _LOGGER.debug("SUPERNOTIFY Skipping delivery %s based on transport disabled", delivery)
368 return
370 delivery_priorities: list[str] = delivery.priority
371 if self.priority and delivery_priorities and self.priority not in delivery_priorities:
372 _LOGGER.debug("SUPERNOTIFY Skipping delivery %s based on priority (%s)", delivery, self.priority)
373 self.record_result(delivery, suppression_reason=SuppressionReason.PRIORITY)
374 return
375 if not delivery.evaluate_conditions(self.condition_variables):
376 _LOGGER.debug("SUPERNOTIFY Skipping delivery %s based on conditions", delivery)
377 self.record_result(delivery, suppression_reason=SuppressionReason.DELIVERY_CONDITION)
378 return
380 targets: list[Target] = self.generate_targets(delivery, recipients=recipients)
381 envelopes: list[Envelope] = self.generate_envelopes(delivery, targets)
382 if not envelopes:
383 if delivery.target_required == TargetRequired.ALWAYS and (
384 not targets or not any(t.has_resolved_target() for t in targets)
385 ):
386 reason: SuppressionReason = SuppressionReason.NO_TARGET
387 else:
388 reason = SuppressionReason.UNKNOWN
389 self.record_result(delivery, targets=targets, suppression_reason=reason)
391 for envelope in envelopes:
392 if self.context.dupe_checker.check(envelope):
393 _LOGGER.debug("SUPERNOTIFY Suppressing dupe envelope, %s", self.message)
394 self.record_result(delivery, envelope, suppression_reason=SuppressionReason.DUPE)
395 continue
396 try:
397 if not await transport.deliver(envelope, debug_trace=self.debug_trace):
398 _LOGGER.debug("SUPERNOTIFY No delivery for %s", delivery.name)
399 self.record_result(delivery, envelope)
400 except Exception as e2:
401 _LOGGER.exception("SUPERNOTIFY Failed to deliver %s: %s", delivery.name, e2)
402 envelope.error_count = envelope.error_count + 1
403 transport.record_error(str(e2), method="deliver")
404 envelope.delivery_error = format_exception(e2)
405 self.record_result(delivery, envelope)
407 except Exception as e:
408 _LOGGER.exception("SUPERNOTIFY Failed to notify using %s", delivery.name)
409 _LOGGER.debug("SUPERNOTIFY %s delivery failure", delivery, exc_info=True)
410 self.deliveries.setdefault(delivery.name, {})
411 self.deliveries[delivery.name].setdefault("errors", [])
412 errors: list[str] = cast("list[str]", self.deliveries[delivery.name]["errors"])
413 errors.append("\n".join(format_exception(e)))
415 def record_result(
416 self,
417 delivery: Delivery | None,
418 envelope: Envelope | None = None,
419 targets: list[Target] | None = None,
420 suppression_reason: SuppressionReason | None = None,
421 ) -> None:
422 """Debugging (and unit test) support for notifications that failed or were skipped"""
423 if delivery:
424 if envelope:
425 self.delivered += envelope.delivered
426 self.error_count += envelope.error_count
427 self.deliveries.setdefault(delivery.name, {})
428 if envelope.delivered:
429 self.deliveries[delivery.name].setdefault(KEY_DELIVERED, [])
430 self.deliveries[delivery.name][KEY_DELIVERED].append(envelope) # type: ignore
431 else:
432 if suppression_reason:
433 envelope.skip_reason = suppression_reason
434 if suppression_reason not in self._skip_reasons:
435 self._skip_reasons.append(suppression_reason)
436 if suppression_reason == SuppressionReason.DUPE:
437 self.dupe = True
438 if envelope.error_count:
439 self.deliveries[delivery.name].setdefault(KEY_FAILED, [])
440 self.deliveries[delivery.name][KEY_FAILED].append(envelope) # type: ignore
441 self.failed += 1
442 else:
443 self.deliveries[delivery.name].setdefault(KEY_SUPPRESSED, [])
444 self.deliveries[delivery.name][KEY_SUPPRESSED].append(envelope) # type: ignore
445 self.suppressed += 1
447 if not envelope:
448 delivery_name: str = delivery.name if delivery else "!UNKNOWN!"
449 skip_summary: dict[str, Any] = {
450 "target_required": delivery.target_required if delivery else "!UNKNOWN!",
451 "suppression_reason": str(suppression_reason),
452 }
453 self.deliveries.setdefault(delivery_name, {})
454 if targets:
455 skip_summary["targets"] = targets
456 self.deliveries[delivery_name][KEY_SKIPPED] = skip_summary
457 self.skipped += 1
459 def contents(self, minimal: bool = False, **_kwargs: Any) -> dict[str, Any]:
460 """ArchiveableObject implementation"""
461 object_refs = ["context", "people_registry", "delivery_registry"]
462 keys_only = ["enabled_scenarios"]
463 debug_only = ["debug_trace"]
464 exposed_if_populated = ["_delivery_error", "message_html", "extra_data", "actions", "_suppression_reason"]
465 # fine tune dict order to ease the eye-burden when reviewing archived notifications
466 preferred_order = [
467 "id",
468 "created",
469 "message",
470 "applied_scenario_names",
471 "constrain_scenario_names",
472 "required_scenario_names",
473 "enabled_scenarios",
474 "selected_scenario_names",
475 "delivery_selection",
476 "delivery_overrides",
477 "delivery_selection",
478 "selected_deliveries",
479 "recipients_override",
480 "delivered",
481 "failed",
482 "suppressed",
483 "skipped",
484 "error_count",
485 "deliveries",
486 ]
487 # preferred fields
488 result = {
489 k: sanitize(
490 self.__dict__[k], minimal=minimal, occupancy_only=True, top_level_keys_only=(minimal and k in keys_only)
491 )
492 for k in preferred_order
493 }
494 # all the rest not explicitly excluded
495 result.update({
496 k: sanitize(v, minimal=minimal, occupancy_only=True)
497 for k, v in self.__dict__.items()
498 if k not in result
499 and k not in exposed_if_populated
500 and k not in object_refs
501 and not k.startswith("_")
502 and (not minimal or k not in keys_only)
503 and (not minimal or k not in debug_only)
504 })
505 # the exposed only if populated fields
506 result.update({
507 k: sanitize(self.__dict__[k], minimal=minimal, occupancy_only=True)
508 for k in exposed_if_populated
509 if self.__dict__.get(k)
510 })
511 return result
513 def base_filename(self) -> str:
514 """ArchiveableObject implementation"""
515 return f"{self.created.isoformat()[:16]}_{self.id}"
517 def delivery_data(self, delivery_name: str) -> dict[str, Any]:
518 delivery_override: DeliveryCustomization | None = self.delivery_overrides.get(delivery_name)
519 return delivery_override.data if delivery_override and delivery_override.data else {}
521 @property
522 def delivered_envelopes(self) -> list[Envelope]:
523 result: list[Envelope] = []
524 for delivery_result in self.deliveries.values():
525 result.extend(cast("list[Envelope]", delivery_result.get(KEY_DELIVERED, [])))
526 return result
528 @property
529 def undelivered_envelopes(self) -> list[Envelope]:
530 result: list[Envelope] = []
531 for delivery_result in self.deliveries.values():
532 result.extend(cast("list[Envelope]", delivery_result.get(KEY_SUPPRESSED, [])))
533 result.extend(cast("list[Envelope]", delivery_result.get(KEY_FAILED, [])))
534 return result
536 async def select_scenarios(self) -> list[str]:
537 return [s.name for s in self.context.scenario_registry.scenarios.values() if s.evaluate(self.condition_variables)]
539 def generate_targets(self, delivery: Delivery, recipients: list[str] | None = None) -> list[Target]:
541 if delivery.target_required == TargetRequired.NEVER:
542 # don't waste time computing targets for deliveries that don't need them
543 return [Target(None, target_data=delivery.data)]
545 computed_target: Target
547 if delivery.target_usage == TARGET_USE_FIXED:
548 if delivery.target:
549 computed_target = delivery.target.safe_copy()
550 self.debug_trace.record_target(delivery.name, "100_delivery_default_fixed", computed_target)
551 else:
552 computed_target = Target(None, target_data=delivery.data)
553 self.debug_trace.record_target(delivery.name, "101_delivery_default_fixed_empty", computed_target)
554 elif recipients is not None:
555 computed_target = Target(recipients)
556 self.debug_trace.record_target(delivery.name, "102_delivery_default_fixed", computed_target)
558 elif not self._target:
559 # Unless there are explicit targets, include everyone on the people registry
560 computed_target = self.default_person_ids(delivery)
561 self.debug_trace.record_target(delivery.name, "201_no_action_target", computed_target)
562 else:
563 computed_target = self._target.safe_copy()
564 self.debug_trace.record_target(delivery.name, "202_action_target", computed_target)
566 # 1st round of filtering for snooze and resolving people->direct targets
567 computed_target = self.context.snoozer.filter_recipients(computed_target, self.priority, delivery)
568 self.debug_trace.record_target(delivery.name, "300_post_snooze", computed_target)
569 # turn person_ids into emails and phone numbers
570 for indirect_target in self.resolve_indirect_targets(computed_target, delivery):
571 computed_target += indirect_target
572 self.debug_trace.record_target(delivery.name, "310_resolve_indirect", computed_target)
573 computed_target += self.resolve_scenario_targets(delivery)
574 self.debug_trace.record_target(delivery.name, "320_resolved_scenario_targets", computed_target)
575 # filter out target not required for this delivery
576 computed_target = delivery.select_targets(computed_target)
577 self.debug_trace.record_target(delivery.name, "330_delivery_selection", computed_target)
578 primary_count = len(computed_target)
580 if delivery.target_usage == TARGET_USE_ON_NO_DELIVERY_TARGETS:
581 if not computed_target.has_targets() and delivery.target:
582 computed_target += delivery.target
583 self.debug_trace.record_target(delivery.name, "400_delivery_default_no_delivery_targets", computed_target)
584 elif delivery.target_usage == TARGET_USE_ON_NO_ACTION_TARGETS:
585 if not self._target and delivery.target:
586 computed_target += delivery.target
587 self.debug_trace.record_target(delivery.name, "401_delivery_default_no_action_targets", computed_target)
588 elif delivery.target_usage == TARGET_USE_MERGE_ON_DELIVERY_TARGETS:
589 # merge in the delivery defaults if there's a target defined in action call
590 if computed_target.has_targets() and delivery.target:
591 computed_target += delivery.target
592 self.debug_trace.record_target(delivery.name, "402_delivery_merge_on_delivery_targets", computed_target)
593 elif delivery.target_usage == TARGET_USE_MERGE_ALWAYS:
594 # merge in the delivery defaults even if there's not a target defined in action call
595 if delivery.target:
596 computed_target += delivery.target
597 self.debug_trace.record_target(delivery.name, "403_delivery_merge_always_targets", computed_target)
598 elif delivery.target_usage == TARGET_USE_FIXED:
599 _LOGGER.debug("SUPERNOTIFY Fixed target on delivery %s", delivery.name)
600 self.debug_trace.record_target(delivery.name, "404_fixed_target", computed_target)
601 else:
602 self.debug_trace.record_target(delivery.name, "405_no_target_usage_match", computed_target)
603 _LOGGER.debug("SUPERNOTIFY No useful target definition for delivery %s", delivery.name)
605 if len(computed_target) > primary_count:
606 _LOGGER.debug(
607 "SUPERNOTIFY Delivery config added %s targets for %s", len(computed_target) - primary_count, delivery.name
608 )
610 # 2nd round of filtering for snooze and resolving people->direct targets after delivery target applied
611 computed_target = self.context.snoozer.filter_recipients(computed_target, self.priority, delivery)
612 self.debug_trace.record_target(delivery.name, "501_post_snooze", computed_target)
613 for indirect_target in self.resolve_indirect_targets(computed_target, delivery):
614 computed_target += indirect_target
615 self.debug_trace.record_target(delivery.name, "502_resolved_indirect_targets", computed_target)
616 computed_target += self.resolve_scenario_targets(delivery)
617 self.debug_trace.record_target(delivery.name, "503_resolved_scenario_targets", computed_target)
618 computed_target = delivery.select_targets(computed_target)
619 self.debug_trace.record_target(delivery.name, "504_delivery_selection", computed_target)
621 split_targets: list[Target] = computed_target.split_by_target_data()
622 self.debug_trace.record_target(delivery.name, "610_delivery_split_targets", split_targets)
624 direct_targets: list[Target] = [t.direct() for t in split_targets]
625 self.debug_trace.record_target(delivery.name, "620_narrow_to_direct", direct_targets)
627 if delivery.options.get(OPTION_UNIQUE_TARGETS, False):
628 direct_targets = [t - self._already_selected for t in direct_targets]
629 self.debug_trace.record_target(delivery.name, "630_make_unique_across_deliveries", direct_targets)
630 for direct_target in direct_targets:
631 self._already_selected += direct_target
632 self.debug_trace.record_target(delivery.name, "999_final_cut", direct_targets)
633 return direct_targets
635 def resolve_scenario_targets(self, delivery: Delivery) -> Target:
636 resolved: Target = Target()
637 for scenario in self.enabled_scenarios.values():
638 customization: DeliveryCustomization | None = scenario.delivery_customization(delivery.name)
639 if customization and customization.target and customization.target.has_targets():
640 resolved += customization.target
641 return resolved
643 def all_recipients(self) -> list[Recipient]:
644 recipients: list[Recipient] = []
645 if self._target:
646 # explicit targets given
647 recipients.extend(
648 self.people_registry.people[pers_ent_id]
649 for pers_ent_id in self._target.person_ids
650 if pers_ent_id in self.people_registry.people and self.people_registry.people[pers_ent_id].enabled
651 )
652 else:
653 # default to all known recipients
654 recipients = self.people_registry.enabled_recipients()
655 recipients = [r for r in recipients if self.recipients_override is None or r.entity_id in self.recipients_override]
656 return recipients
658 def default_person_ids(self, delivery: Delivery) -> Target:
659 # If target not specified on service call or delivery, then default to std list of recipients
660 people: list[Recipient] = self.people_registry.filter_recipients_by_occupancy(delivery.occupancy)
661 people = [p for p in people if self.recipients_override is None or p.entity_id in self.recipients_override]
662 return Target({ATTR_PERSON_ID: [p.entity_id for p in people if p.entity_id]})
664 def resolve_indirect_targets(self, target: Target, delivery: Delivery) -> list[Target]:
665 # enrich data selected in configuration for this delivery, from direct target definition or attrs like email or phone
666 resolved: Target = Target()
667 additional: list[Target] = []
669 for person_id in target.person_ids:
670 recipient: Recipient | None = self.people_registry.people.get(person_id)
671 if recipient and recipient.enabled:
672 recipient_target = recipient.target(delivery.name)
673 if recipient_target.target_specific_data:
674 additional.append(recipient_target)
675 else:
676 resolved += recipient_target
677 else:
678 _LOGGER.debug("SUPERNOTIFY Skipping recipient %s with enabled switched off", person_id)
680 return [resolved, *additional]
682 def generate_envelopes(self, delivery: Delivery, targets: list[Target]) -> list[Envelope]:
683 # now the list of recipients determined, resolve this to target addresses or entities
685 envelopes: list[Envelope] = []
686 for target in targets:
687 # a target is always generated, even if there are no recipients
688 if target.has_resolved_target() or delivery.target_required != TargetRequired.ALWAYS:
689 envelope_data = {}
690 envelope_data.update(delivery.data)
691 envelope_data.update(self.extra_data) # action call data
692 if target.target_data:
693 envelope_data.update(target.target_data)
694 # scenario applied at cross-delivery level in apply_enabled_scenarios
695 for scenario in self.enabled_scenarios.values():
696 customization: DeliveryCustomization | None = scenario.delivery_customization(delivery.name)
697 if customization and customization.data:
698 envelope_data.update(customization.data)
699 envelopes.append(Envelope(delivery, self, target, envelope_data, context=self.context))
701 return envelopes