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

1"""Mobile App Companion transport for SuperNotify. 

2 

3Sends push notifications to HA Companion App on iOS and Android devices. 

4Supports per-device delivery with automatic snooze on failure. 

5 

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 

14 

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""" 

53 

54from __future__ import annotations 

55 

56import logging 

57import time 

58from asyncio import sleep 

59from collections.abc import Collection 

60from datetime import timedelta 

61from typing import TYPE_CHECKING, Any, ClassVar 

62 

63from aiohttp import ClientResponse, ClientSession, ClientTimeout 

64from bs4 import BeautifulSoup 

65from homeassistant.components.notify.const import ATTR_DATA 

66from homeassistant.helpers.typing import ConfigType 

67 

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 

110 

111if TYPE_CHECKING: 

112 from custom_components.supernotify.envelope import Envelope 

113 from custom_components.supernotify.hass_api import HomeAssistantAPI, TrackedDeviceDetails 

114 

115_LOGGER = logging.getLogger(__name__) 

116 

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} 

125 

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 

135 

136 

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 ] 

144 

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] = {} 

149 

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 ) 

160 

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

162 return {"action_titles": self.action_titles, "action_title_failures": self.action_title_failures} 

163 

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 

176 

177 @property 

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

179 return [ATTR_MOBILE_APP_ID] 

180 

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

182 return hass_api.find_config_entry_data("mobile_app") is not None 

183 

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

185 return {self.name: {}} 

186 

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

188 return action is None 

189 

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. 

192 

193 Modifies raw_data in-place via pop(). 

194 After this call, raw_data contains only passthrough keys for the Companion App. 

195 

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 } 

222 

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. 

230 

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 

246 

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 

252 

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 

258 

259 return android_data 

260 

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 

292 

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 

314 

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 

319 

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) 

323 

324 action_groups = envelope.action_groups 

325 _LOGGER.debug("SUPERNOTIFY notify_mobile: %s -> %s", envelope.title, envelope.target.mobile_app_ids) 

326 

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 ) 

331 

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 

338 

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) 

356 

357 # 4. iOS extra fields 

358 

359 if push_data["subtitle"]: 

360 ios_data["subtitle"] = push_data["subtitle"] 

361 

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) 

365 

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 ) 

374 

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) 

381 

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 

402 

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 

406 

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 

454 

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]]] = [] 

460 

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 

467 

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 ) 

495 

496 action_data = envelope.core_action_data() 

497 action_data[ATTR_DATA] = target_data 

498 action_data = envelope.customize_data(action_data) 

499 

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 ) 

519 

520 if success and is_android: 

521 tts_pending.extend((full_target, dict(extra)) for extra in android_after) 

522 

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