Coverage for custom_components/supernotify/transports/mobile_push.py: 100%
242 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
1"""Mobile App Companion transport for SuperNotify.
3Sends push notifications to HA Companion App on iOS and Android devices.
4Supports per-device delivery with automatic snooze on failure.
6Priority mapping (auto, overridable via mobile_push_critical_level):
7 critical → iOS: interruption_level=critical
8 Android: ttl=0, priority=high, channel=alarm_stream (sounds through Do Not Disturb
9 and silent mode); a `ttl` or `channel` already in the data is kept
10 high → iOS: interruption_level=time-sensitive
11 medium → iOS: interruption_level=active (default)
12 low → iOS: interruption_level=passive
13 minimum → iOS: interruption_level=passive
15New data keys (all optional):
16 mobile_push_critical_level str iOS interruption_level override
17 ("passive","active","time-sensitive","critical")
18 If omitted, auto-mapped from SuperNotify priority.
19 mobile_push_critical_ttl int Android FCM TTL (0=no caching/instant).
20 Auto-set to 0 for critical priority if not set.
21 mobile_push_critical_priority str Android FCM priority override ("high" or "normal").
22 Auto-set to "high" for critical priority if not set.
23 mobile_push_subtitle str iOS subtitle (line between title and message, iOS 10+)
24 mobile_push_group str Notification group for visual stacking (iOS thread-id / Android group).
25 Falls back to the camera entity id if there's a camera image,
26 otherwise left unset (notification appears individually).
27 mobile_push_notification_tag str Notification tag for replacement (iOS) / grouping (Android)
28 mobile_push_clear_notification bool Send clear_notification to dismiss previous same-tag notification,
29 instead of the notification itself.
30 Requires mobile_push_notification_tag to be set.
31 mobile_push_tts_text str Android: text read aloud by the phone (Android 8+), sent as its own
32 `message: TTS` call after the notification, on the alarm stream at
33 full volume for critical. If omitted, push TTS is not activated.
34 mobile_push_tts_delay int Seconds between the notification and its TTS, so the notification's
35 own sound isn't cut off (default 5, 0 for straight away).
36 mobile_push_tts_locale str BCP-47 language for TTS (e.g. "it-IT", "en-US").
37 Only used when mobile_push_tts_text is set.
38 mobile_push_tts_engine str TTS engine package (e.g. "com.google.android.tts").
39 Only used when mobile_push_tts_text is set.
40 mobile_push_command_screen_on bool Android: turn on device screen on delivery (Android 8+),
41 or "keep_screen_on"
42 mobile_push_command_dnd str Android: change Do Not Disturb ("alarms_only","priority_only",
43 "total_silence","off")
44 mobile_push_command_ringer_mode str Android: change ringer mode ("normal","silent","vibrate")
45 The command_* keys are each sent as their own `message: command_...`
46 call before the notification, as the companion app takes them.
47 mobile_push_channel_override str Android notification channel override (e.g. "alarm","general")
48 mobile_push_alarm_stream bool Android: use the alarm_stream channel, which sounds through
49 Do Not Disturb and silent mode
50 mobile_push_alarm_stream_max bool Android: kept for compatibility, same as mobile_push_alarm_stream -
51 the companion app only supports alarm_stream_max for TTS
52"""
54from __future__ import annotations
56import logging
57import time
58from asyncio import sleep
59from collections.abc import Collection
60from datetime import timedelta
61from typing import TYPE_CHECKING, Any, ClassVar
63from aiohttp import ClientResponse, ClientSession, ClientTimeout
64from bs4 import BeautifulSoup
65from homeassistant.components.notify.const import ATTR_DATA
66from homeassistant.helpers.typing import ConfigType
68from custom_components.supernotify import const
69from custom_components.supernotify.const import (
70 ACTION_LEVEL_NOTIFICATION,
71 ATTR_ACTION_URL,
72 ATTR_ACTION_URL_TITLE,
73 ATTR_DEFAULT,
74 ATTR_IMAGE,
75 ATTR_MEDIA_CAMERA_ENTITY_ID,
76 ATTR_MEDIA_CLIP_URL,
77 ATTR_MEDIA_SNAPSHOT_PATH,
78 ATTR_MEDIA_SNAPSHOT_URL,
79 ATTR_MOBILE_APP_ID,
80 ATTR_VIDEO,
81 CONF_LEVEL,
82 MANUFACTURER_APPLE,
83 TRANSPORT_MOBILE_PUSH,
84)
85from custom_components.supernotify.media_grab import select_avail_camera
86from custom_components.supernotify.model import (
87 CommandType,
88 DebugTrace,
89 MessageOnlyPolicy,
90 QualifiedTargetType,
91 RecipientType,
92 SelectionRule,
93 TransportConfig,
94 TransportFeature,
95)
96from custom_components.supernotify.options import (
97 DATA_KEYS_SELECT_OPTION,
98 MEDIA_OPTIONS,
99 OPTION_DATA_KEYS_SELECT,
100 OPTION_DEVICE_DISCOVERY,
101 OPTION_DEVICE_DOMAIN,
102 OPTION_DEVICE_MODEL_SELECT,
103 OPTION_MESSAGE_USAGE,
104 OPTION_SIMPLIFY_TEXT,
105 OPTION_STRIP_URLS,
106 DeliveryOption,
107)
108from custom_components.supernotify.target import Target, TargetEntityCategory
109from custom_components.supernotify.transport import Transport
111if TYPE_CHECKING:
112 from custom_components.supernotify.envelope import Envelope
113 from custom_components.supernotify.hass_api import HomeAssistantAPI, TrackedDeviceDetails
115_LOGGER = logging.getLogger(__name__)
117# iOS interruption_level mapping from SuperNotify priority
118IOS_INTERRUPTION_MAP: dict[str, str] = {
119 const.PRIORITY_CRITICAL: "critical",
120 const.PRIORITY_HIGH: "time-sensitive",
121 const.PRIORITY_MEDIUM: "active",
122 const.PRIORITY_LOW: "passive",
123 const.PRIORITY_MINIMUM: "passive",
124}
126# Android FCM TTL auto-set for critical priority (0 = instant, no FCM caching)
127ANDROID_CRITICAL_TTL = 0
128# Android FCM priority and channel auto-set for critical priority - what the companion app
129# documents for critical notifications, alarm_stream being what gets through Do Not Disturb
130ANDROID_CRITICAL_PRIORITY = "high"
131ANDROID_ALARM_STREAM_CHANNEL = "alarm_stream"
132# seconds between a notification and its TTS on Android - started together, the TTS cuts off the
133# notification's own sound
134ANDROID_TTS_DELAY = 5
137class MobilePushTransport(Transport):
138 name = TRANSPORT_MOBILE_PUSH
139 integration_domain = "mobile_app"
140 declared_options: ClassVar[list[DeliveryOption]] = [
141 *MEDIA_OPTIONS,
142 DATA_KEYS_SELECT_OPTION,
143 ]
145 def __init__(self, *args: Any, **kwargs: Any) -> None:
146 super().__init__(*args, **kwargs)
147 self.action_titles: dict[str, str] = {}
148 self.action_title_failures: dict[str, float] = {}
150 @property
151 def supported_features(self) -> TransportFeature:
152 return (
153 TransportFeature.MESSAGE
154 | TransportFeature.TITLE
155 | TransportFeature.ACTIONS
156 | TransportFeature.IMAGES
157 | TransportFeature.VIDEO
158 | TransportFeature.SNAPSHOT_IMAGE
159 )
161 def extra_attributes(self) -> dict[str, Any]:
162 return {"action_titles": self.action_titles, "action_title_failures": self.action_title_failures}
164 @property
165 def default_config(self) -> TransportConfig:
166 config = super().default_config
167 config.delivery_defaults.options = {
168 OPTION_SIMPLIFY_TEXT: False,
169 OPTION_STRIP_URLS: False,
170 OPTION_MESSAGE_USAGE: MessageOnlyPolicy.STANDARD,
171 OPTION_DEVICE_DISCOVERY: False,
172 OPTION_DATA_KEYS_SELECT: None,
173 OPTION_DEVICE_DOMAIN: ["mobile_app"],
174 }
175 return config
177 @property
178 def unique_target_categories(self) -> list[str | TargetEntityCategory]:
179 return [ATTR_MOBILE_APP_ID]
181 def is_viable(self, hass_api: HomeAssistantAPI) -> bool:
182 return hass_api.find_config_entry_data("mobile_app") is not None
184 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
185 return {self.name: {}}
187 def validate_action(self, action: str | None) -> bool:
188 return action is None
190 def _extract_push_data(self, raw_data: dict[str, Any]) -> dict[str, Any]:
191 """Extract and remove SuperNotify-specific push_* keys from raw_data.
193 Modifies raw_data in-place via pop().
194 After this call, raw_data contains only passthrough keys for the Companion App.
196 Returns a dict with all extracted push_* values (None if not provided).
197 """
198 return {
199 # iOS
200 "critical_level_ios": raw_data.pop("mobile_push_critical_level", None),
201 "subtitle": raw_data.pop("mobile_push_subtitle", None),
202 # Android critical
203 "critical_ttl": raw_data.pop("mobile_push_critical_ttl", None),
204 "critical_android_priority": raw_data.pop("mobile_push_critical_priority", None),
205 "channel_override": raw_data.pop("mobile_push_channel_override", None),
206 "alarm_stream": raw_data.pop("mobile_push_alarm_stream", False),
207 "alarm_stream_max": raw_data.pop("mobile_push_alarm_stream_max", False),
208 # Android TTS
209 "tts_text": raw_data.pop("mobile_push_tts_text", None),
210 "tts_locale": raw_data.pop("mobile_push_tts_locale", None),
211 "tts_engine": raw_data.pop("mobile_push_tts_engine", None),
212 "tts_delay": raw_data.pop("mobile_push_tts_delay", ANDROID_TTS_DELAY),
213 # Android Notification Commands
214 "command_screen_on": raw_data.pop("mobile_push_command_screen_on", None),
215 "command_dnd": raw_data.pop("mobile_push_command_dnd", None),
216 "command_ringer_mode": raw_data.pop("mobile_push_command_ringer_mode", None),
217 # Cross-platform
218 "group": raw_data.pop("mobile_push_group", None),
219 "notification_tag": raw_data.pop("mobile_push_notification_tag", None),
220 "clear_notification": raw_data.pop("mobile_push_clear_notification", False),
221 }
223 def _android_payload(
224 self,
225 push_data: dict[str, Any],
226 priority: str | None,
227 given: Collection[str] = (),
228 ) -> dict[str, Any]:
229 """Apply Android-specific fields to the notification data dict.
231 Android fields live flat in data{}, not inside the push{} sub-dict. `given` are the keys
232 the pass-through data already has: the companion app's own `channel` and `ttl` are left
233 as given, rather than replaced by what critical would set (`priority` in the data is
234 SuperNotify's own, so the FCM priority is set with mobile_push_critical_priority).
235 """
236 android_data: dict[str, Any] = {}
237 critical: bool = priority == const.PRIORITY_CRITICAL
238 # Channel override (Android 8+, determines sound/vibration/LED)
239 if push_data["channel_override"]:
240 android_data["channel"] = push_data["channel_override"]
241 elif push_data["alarm_stream"] or push_data["alarm_stream_max"]:
242 # the companion app takes the alarm stream as a channel, not a flag of its own
243 android_data["channel"] = ANDROID_ALARM_STREAM_CHANNEL
244 elif critical and "channel" not in given:
245 android_data["channel"] = ANDROID_ALARM_STREAM_CHANNEL
247 # FCM TTL: auto-set to 0 for critical (instant delivery, no FCM caching)
248 if push_data["critical_ttl"] is not None:
249 android_data["ttl"] = push_data["critical_ttl"]
250 elif critical and "ttl" not in given:
251 android_data["ttl"] = ANDROID_CRITICAL_TTL
253 # FCM priority: high for critical, so a dozing phone gets it straight away
254 if push_data["critical_android_priority"] is not None:
255 android_data["priority"] = push_data["critical_android_priority"]
256 elif critical:
257 android_data["priority"] = ANDROID_CRITICAL_PRIORITY
259 return android_data
261 def _android_extra_calls(
262 self, push_data: dict[str, Any], priority: str | None
263 ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
264 """Notification commands and TTS, which the companion app takes as the `message` of a call of
265 their own rather than as notification data - commands to go before the notification, so the
266 screen is on or Do Not Disturb off when it arrives, and TTS after it."""
267 # a call without high priority can wait until the phone is unlocked - a critical's TTS would then be
268 # spoken late, maybe twice - so for critical these go as fast as the notification itself
269 urgent: dict[str, Any] = (
270 {"ttl": ANDROID_CRITICAL_TTL, "priority": ANDROID_CRITICAL_PRIORITY} if priority == const.PRIORITY_CRITICAL else {}
271 )
272 before: list[dict[str, Any]] = []
273 for key in ("command_dnd", "command_ringer_mode", "command_screen_on"):
274 value = push_data[key]
275 if value:
276 before.append({"message": key, ATTR_DATA: ({} if value is True else {"command": value}) | urgent})
277 after: list[dict[str, Any]] = []
278 if push_data["tts_text"]:
279 tts_data: dict[str, Any] = {"tts_text": push_data["tts_text"], **urgent}
280 if push_data["alarm_stream_max"] or priority == const.PRIORITY_CRITICAL:
281 # at full volume for critical - on a watch the alarm volume can be too low to hear,
282 # and the companion app puts the volume back afterwards
283 tts_data["media_stream"] = "alarm_stream_max"
284 elif push_data["alarm_stream"]:
285 tts_data["media_stream"] = ANDROID_ALARM_STREAM_CHANNEL
286 if push_data["tts_locale"]:
287 tts_data["tts_text_language"] = push_data["tts_locale"]
288 if push_data["tts_engine"]:
289 tts_data["tts_engine"] = push_data["tts_engine"]
290 after.append({"message": "TTS", ATTR_DATA: tts_data})
291 return before, after
293 async def action_title(self, url: str, retry_timeout: int = 900) -> str | None:
294 """Attempt to create a title for mobile action from the TITLE of the web page at the URL"""
295 if url in self.action_titles:
296 return self.action_titles[url]
297 if url in self.action_title_failures and time.time() - self.action_title_failures[url] < retry_timeout:
298 # don't retry too often
299 _LOGGER.debug("SUPERNOTIFY Skipping retry after previous failure to retrieve url title for %s", url)
300 return None
301 try:
302 websession: ClientSession = self.context.hass_api.http_session()
303 resp: ClientResponse = await websession.get(url, timeout=ClientTimeout(total=5.0))
304 body = await resp.content.read()
305 # wrap heavy bs4 parsing in a job to avoid blocking the event loop
306 html = await self.context.hass_api.create_job(BeautifulSoup, body, "html.parser")
307 if html.title and html.title.string:
308 self.action_titles[url] = html.title.string
309 return html.title.string
310 except Exception as e:
311 _LOGGER.warning("SUPERNOTIFY Failed to retrieve url title at %s: %s", url, e)
312 self.action_title_failures[url] = time.time()
313 return None
315 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
316 if not envelope.target.mobile_app_ids:
317 _LOGGER.warning("SUPERNOTIFY No targets provided for mobile_push")
318 return False
320 # 1. Extract SuperNotify push_* keys; raw_data becomes passthrough-only
321 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
322 push_data = self._extract_push_data(raw_data)
324 action_groups = envelope.action_groups
325 _LOGGER.debug("SUPERNOTIFY notify_mobile: %s -> %s", envelope.title, envelope.target.mobile_app_ids)
327 # 2. Build iOS interruption_level
328 ios_level = push_data["critical_level_ios"] or IOS_INTERRUPTION_MAP.get(
329 envelope.priority or const.PRIORITY_MEDIUM, "active"
330 )
332 # 3. Start with passthrough data, then layer SuperNotify fields
333 # `push` is iOS only, and starts from what's given, so its own `sound` etc are kept
334 given_push: dict[str, Any] = raw_data.pop("push", None) or {}
335 data: dict[str, Any] = dict(raw_data)
336 ios_data: dict[str, Any] = {"push": dict(given_push)}
337 ios_data["push"]["interruption-level"] = ios_level
339 if ios_level == "critical":
340 # critical needs the dict form of `sound`, which can otherwise be given as just a name
341 sound: str | dict[str, Any] | None = ios_data["push"].get("sound")
342 critical_sound: dict[str, Any] = {"name": sound} if isinstance(sound, str) else dict(sound or {})
343 critical_sound.setdefault("name", ATTR_DEFAULT)
344 critical_sound["critical"] = 1
345 critical_sound.setdefault("volume", 1.0)
346 ios_data["push"]["sound"] = critical_sound
347 # critical notifications cannot be grouped on iOS
348 else:
349 media = envelope.media or {}
350 camera_entity_id_for_group = media.get(ATTR_MEDIA_CAMERA_ENTITY_ID)
351 group = push_data["group"] or camera_entity_id_for_group
352 # unlike `tag`, an unset `group` leaves notifications ungrouped on the device
353 # (companion app default) rather than forcing them all into a shared bucket
354 if group:
355 data.setdefault("group", group)
357 # 4. iOS extra fields
359 if push_data["subtitle"]:
360 ios_data["subtitle"] = push_data["subtitle"]
362 # 5. Android-specific fields
363 android_data: dict[str, Any] = self._android_payload(push_data, envelope.priority, given=raw_data.keys())
364 android_before, android_after = self._android_extra_calls(push_data, envelope.priority)
366 # 6. Cross-platform: notification tag
367 notification_tag = push_data["notification_tag"]
368 if notification_tag:
369 data["tag"] = notification_tag
370 elif push_data["clear_notification"]:
371 _LOGGER.warning(
372 "SUPERNOTIFY mobile_push: mobile_push_clear_notification needs mobile_push_notification_tag to be set — ignoring"
373 )
375 # 7. Media: camera entity (grab processed image) + fallback URLs
376 media = envelope.media or {}
377 camera_entity_id = media.get(ATTR_MEDIA_CAMERA_ENTITY_ID)
378 # Remove self.hass_api.abs_url for clip_url and snapshot_url
379 clip_url: str | None = media.get(ATTR_MEDIA_CLIP_URL)
380 snapshot_url: str | None = media.get(ATTR_MEDIA_SNAPSHOT_URL)
382 if camera_entity_id or media.get(ATTR_MEDIA_SNAPSHOT_PATH):
383 image_path = await envelope.grab_image()
384 if image_path:
385 image_url = await self.context.media_storage.share_path(image_path)
386 data[ATTR_IMAGE] = image_url or str(image_path)
387 elif camera_entity_id:
388 # fall back to letting device take the image, but only from a camera that's up,
389 # since one that's switched off or unavailable would only show a broken image.
390 # camera_entity_id itself already failed the grab above - exclude it here, since
391 # a camera disabled at the device (rather than truly unavailable) won't show that
392 # in its entity state, so re-offering it would just repeat the same failed fetch
393 available_camera_entity_id = select_avail_camera(
394 self.hass_api, self.context.cameras, camera_entity_id, exclude_primary=True
395 )
396 if available_camera_entity_id:
397 data["entity_id"] = available_camera_entity_id
398 else:
399 _LOGGER.info("SUPERNOTIFY mobile_push: no available camera for %s, sending without image", camera_entity_id)
400 if clip_url:
401 data[ATTR_VIDEO] = clip_url
403 if snapshot_url and ATTR_IMAGE not in data:
404 # Fallback: use pre-computed snapshot URL if grab_image() produced nothing
405 data[ATTR_IMAGE] = snapshot_url
407 # 8. Actions: URL-title fetching, snooze action, action groups (unchanged)
408 if "actions" in data and not isinstance(data["actions"], list):
409 _LOGGER.warning(
410 "SUPERNOTIFY mobile_push: data.actions must be a list of action objects, ignoring invalid value %s",
411 data["actions"],
412 )
413 data["actions"] = []
414 else:
415 data.setdefault("actions", [])
416 for action in envelope.actions:
417 if action.get(CONF_LEVEL) == ACTION_LEVEL_NOTIFICATION:
418 continue
419 button = envelope.render_action(action)
420 app_url: str | None = self.hass_api.abs_url(button.get(ATTR_ACTION_URL))
421 if app_url:
422 app_url_title = (
423 button.get(ATTR_ACTION_URL_TITLE)
424 or await self.action_title(app_url)
425 or await self.common_string("action_url_title")
426 )
427 button[ATTR_ACTION_URL_TITLE] = app_url_title
428 data["actions"].append(button)
429 if camera_entity_id:
430 camera_state = self.hass_api.get_state(camera_entity_id)
431 data["actions"].append({
432 "action": f"SUPERNOTIFY_SNOOZE_EVERYONE_CAMERA_{camera_entity_id}",
433 "title": (await self.common_string("snooze_camera_title")).format(
434 camera=camera_state.name if camera_state else camera_entity_id
435 ),
436 "behavior": "textInput",
437 "textInputButtonTitle": await self.common_string("snooze_camera_button"),
438 "textInputPlaceholder": "60",
439 })
440 for group, actions in self.context.mobile_actions.items():
441 if action_groups is None or group in action_groups:
442 data["actions"].extend(
443 envelope.render_action(action) for action in actions if action.get(CONF_LEVEL) != ACTION_LEVEL_NOTIFICATION
444 )
445 if not data["actions"]:
446 del data["actions"]
447 # tapping the notification itself opens the uri of a notification level action
448 notification_uri: str | None = envelope.notification_uri()
449 if notification_uri:
450 if "url" not in data:
451 ios_data["url"] = notification_uri
452 if "clickAction" not in data:
453 android_data["clickAction"] = notification_uri
455 # 9. Dispatch to each mobile target
456 clear_notification = bool(push_data["clear_notification"] and notification_tag)
457 model_filter = SelectionRule(envelope.delivery.options.get(OPTION_DEVICE_MODEL_SELECT))
458 hits = 0
459 tts_pending: list[tuple[str, dict[str, Any]]] = []
461 for mobile_target in envelope.target.mobile_app_ids:
462 full_target = mobile_target if Target.is_notify_entity(mobile_target) else f"notify.{mobile_target}"
463 mobile_info: TrackedDeviceDetails | None = self.context.hass_api.mobile_app_by_id(mobile_target)
464 if mobile_info is not None and not model_filter.match(mobile_info.model):
465 _LOGGER.debug("SUPERNOTIFY Skipping %s, model %s excluded by delivery filter", mobile_target, mobile_info.model)
466 continue
468 # fresh copy per target - customize_data below may prune `data` down to nothing
469 # (e.g. an Android target with no android/ios fields to merge in), and that must
470 # not carry over and clobber the next target's action_data
471 target_data = dict(data)
472 # the companion app registers its os_name, the manufacturer is only a guess for older registrations
473 is_android: bool = mobile_info is not None and (
474 mobile_info.os_name.lower() == "android"
475 if mobile_info.os_name
476 else mobile_info.manufacturer != MANUFACTURER_APPLE
477 )
478 if mobile_info is None:
479 target_data.update(android_data)
480 target_data.update(ios_data)
481 if android_before or android_after:
482 # an iPhone would show a command or TTS call as a notification saying "command_dnd"
483 _LOGGER.debug(
484 "SUPERNOTIFY mobile_push: not sending Android commands/TTS to unknown device %s", mobile_target
485 )
486 elif is_android:
487 target_data.update(android_data)
488 else:
489 target_data.update(ios_data)
490 if is_android:
491 for extra in android_before:
492 await self.call_action(
493 envelope, qualified_action=full_target, action_data=dict(extra), implied_target=True, single_target=True
494 )
496 action_data = envelope.core_action_data()
497 action_data[ATTR_DATA] = target_data
498 action_data = envelope.customize_data(action_data)
500 if clear_notification:
501 # Override message to "clear_notification" to dismiss same-tag notification on device
502 clear_action_data = dict(action_data)
503 clear_action_data["message"] = "clear_notification"
504 success = await self.call_action(
505 envelope,
506 qualified_action=full_target,
507 action_data=clear_action_data,
508 implied_target=True,
509 single_target=True,
510 )
511 else:
512 success = await self.call_action(
513 envelope,
514 qualified_action=full_target,
515 action_data=action_data,
516 implied_target=True,
517 single_target=True,
518 )
520 if success and is_android:
521 tts_pending.extend((full_target, dict(extra)) for extra in android_after)
523 if success:
524 hits += 1
525 else:
526 simple_target = (
527 mobile_target if not Target.is_notify_entity(mobile_target) else mobile_target.replace("notify.", "")
528 )
529 # a device whose notify action has gone (removed, or re-paired under another name)
530 # won't come back by waiting, so it gets a repair rather than a snooze that hides it
531 action_gone: bool = not self.context.hass_api.mobile_app_action_exists(simple_target)
532 if action_gone:
533 _LOGGER.warning("SUPERNOTIFY No notify action for %s, raising a repair", simple_target)
534 else:
535 _LOGGER.warning("SUPERNOTIFY Failed to send to %s, snoozing for a day", simple_target)
536 if self.people_registry:
537 # tie the mobile device back to a recipient for the snoozing API
538 for recipient in self.people_registry.enabled_recipients():
539 for md in recipient.mobile_devices:
540 if md not in (simple_target, mobile_target):
541 continue
542 if action_gone:
543 recipient.check_mobile_actions(self.context.hass_api)
544 else:
545 self.context.snoozer.register_snooze(
546 CommandType.SNOOZE,
547 target_type=QualifiedTargetType.MOBILE,
548 target=simple_target,
549 recipient_type=RecipientType.USER,
550 recipient=recipient.entity_id,
551 snooze_for=timedelta(days=1),
552 reason="Action Failure",
553 )
554 if tts_pending:
555 # one wait for all the devices, after every notification has gone
556 if push_data["tts_delay"]:
557 await sleep(push_data["tts_delay"])
558 for full_target, extra in tts_pending:
559 await self.call_action(
560 envelope, qualified_action=full_target, action_data=extra, implied_target=True, single_target=True
561 )
562 return hits > 0