Coverage for custom_components/supernotify/transports/alexa_media_player.py: 100%

152 statements  

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

1"""Alexa Media Player transport adaptor for Supernotify. 

2 

3Volume management: Amazon Alexa API does not expose a per-announcement 

4volume parameter in notify.alexa_media. This adaptor handles it natively: 

5 

61. Snapshot - reads current volume_level of every target media_player. 

7 If None (AMP startup bug, issue #1394), uses volume_fallback. 

82. Pause/Stop- If pause_music=True and playing: media_pause only (preserves 

9 streaming session for resume). No media_stop — calling it after 

10 media_pause kills Spotify/streaming and prevents resume. 

11 - If pause_music=False and playing: media_stop only (suppresses 

12 Alexa beep before volume_set; no resume expected). 

13 - If idle: neither (media_stop on idle Alexa triggers a beep). 

143. Set vol - media_player.volume_set on every target. 

154. Announce - notify.alexa_media without volume in payload. 

165. Wait - estimates TTS duration, SSML-aware (energywave/multinotify). 

17 Skipped when wait_for_tts=False (default) and there is no volume 

18 to restore or music to resume (fire-and-forget mode). 

19 Duration calibrated per-language via tts_char_speed. 

206. Restore - media_player.volume_set back to previous level. 

21 No media_stop in post-announce: Alexa is already idle after 

22 TTS, calling media_stop would produce another unwanted beep. 

237. Resume - media_player.media_play after 2s delay if was playing. 

24 

25Data keys (all optional): 

26 type str announce (default) | tts | push, passed to notify.alexa_media 

27 method str all | speak | show, passed to notify.alexa_media 

28 volume float 0-1 desired announcement volume 

29 restore_volume bool restore previous volume (default True) 

30 pause_music bool pause music if playing (default True) 

31 volume_fallback float 0-1 fallback when volume_level is None (default 0.5) 

32 wait_for_tts bool block until TTS finishes before returning. 

33 Default False (fire-and-forget). 

34 Set True to sequence automation actions after 

35 the announcement (e.g. "open blinds only after 

36 Alexa has finished speaking"). 

37 When volume/music restore is active this wait 

38 happens implicitly; wait_for_tts=True only adds 

39 extra blocking in pure fire-and-forget deliveries. 

40 audio_url str play an audio file (e.g. an mp3 chime) before the message, 

41 via an SSML <audio> tag. Relative URLs like 

42 /local/sounds/bell.mp3 are made absolute with the 

43 external URL. Amazon fetches the file from its own 

44 cloud, so it must be public https with a valid 

45 certificate, MP3 at 48kbps and 16000/22050/24000 Hz. 

46 Forces `type` to `tts` (Alexa is silent for SSML audio 

47 in announce mode), and the message, if any, is spoken 

48 after the audio as plain text. 

49 audio_duration float s length of the audio_url clip, added to the TTS wait 

50 so volume restore/music resume don't cut it short. 

51 Default 0 (the 5s base wait covers short chimes). 

52 tts_char_speed float s/ch seconds per character for TTS duration estimate. 

53 Default 0.06 (Italian/English calibration). 

54 Suggested values by language family: 

55 Italian / English / French : 0.060 

56 Spanish / Portuguese : 0.058 

57 German : 0.065 

58 Russian / Polish : 0.062 

59 Japanese / Chinese / Korean : 0.180 

60 Arabic : 0.075 

61 

62 

63References: 

64- energywave/multinotify https://github.com/energywave/multinotify 

65- ago19800/centralino https://github.com/ago19800/centralino 

66- jumping2000/universal_notifier https://github.com/jumping2000/universal_notifier 

67- AMP issue #1394 https://github.com/alandtse/alexa_media_player/issues/1394 

68- AMP discussion #2782 https://github.com/alandtse/alexa_media_player/discussions/2782 

69- multinotify issue #6 https://github.com/energywave/multinotify/issues/6 

70 

71""" 

72 

73from __future__ import annotations 

74 

75import asyncio 

76import html 

77import logging 

78import re 

79import urllib.parse 

80from asyncio import sleep 

81from typing import TYPE_CHECKING, Any, ClassVar 

82 

83from homeassistant.components.notify.const import ATTR_DATA, ATTR_TARGET 

84from homeassistant.const import ( 

85 ATTR_ENTITY_ID, 

86) 

87from homeassistant.helpers import config_validation as cv 

88 

89from custom_components.supernotify.common import boolify 

90from custom_components.supernotify.const import ( 

91 RE_MEDIA_PLAYER_ENTITY_ID, 

92 TRANSPORT_ALEXA_MEDIA_PLAYER, 

93) 

94from custom_components.supernotify.model import ( 

95 DebugTrace, 

96 MessageOnlyPolicy, 

97 TransportConfig, 

98 TransportFeature, 

99) 

100from custom_components.supernotify.options import ( 

101 OPTION_MESSAGE_USAGE, 

102 OPTION_SIMPLIFY_TEXT, 

103 OPTION_STRIP_URLS, 

104 OPTION_TARGET_SELECT, 

105 DeliveryOption, 

106) 

107from custom_components.supernotify.target import TargetEntityCategory 

108from custom_components.supernotify.transport import Transport 

109 

110if TYPE_CHECKING: 

111 from homeassistant.core import Context as HAContext 

112 

113 from custom_components.supernotify.envelope import Envelope 

114 

115# alandtse/alexa_media_player HACS integration's notify platform module 

116HA_ALEXA_MEDIA_PLAYER_MODULE = "custom_components.alexa_media.notify" 

117# the entity registry platform for the media_player entities this integration creates 

118HA_ALEXA_MEDIA_PLAYER_PLATFORM = "alexa_media" 

119 

120 

121RE_SSML_TAG = re.compile(r"<[^>]+>") 

122PAUSE_CHARS = (", ", ". ", "! ", "? ", ": ", "; ") 

123 

124_PAUSE_WEIGHT = 0.35 

125_CHAR_WEIGHT = 0.06 

126_BASE_DURATION = 5.0 

127_MUSIC_RESUME_DELAY = 2.0 

128 

129_LOGGER = logging.getLogger(__name__) 

130 

131OPTION_MEDIA_AUTO_PAUSE = "media_auto_pause" 

132 

133 

134def _estimate_tts_duration(message: str, char_weight: float = _CHAR_WEIGHT) -> float: 

135 """Estimate pronunciation duration in seconds, stripping SSML first. 

136 

137 Formula from energywave/multinotify: 

138 duration = BASE + pause_chars x PAUSE_WEIGHT + chars x char_weight 

139 

140 Args: 

141 message: The TTS message (SSML tags are stripped before counting). 

142 char_weight: Seconds per plain-text character. Override via the 

143 ``tts_char_speed`` data key to calibrate for the TTS 

144 language (default 0.06 s/ch — Italian/English). 

145 

146 """ 

147 plain = RE_SSML_TAG.sub("", message) 

148 pause_count = sum(plain.count(p) for p in PAUSE_CHARS) 

149 return _BASE_DURATION + pause_count * _PAUSE_WEIGHT + len(plain) * char_weight 

150 

151 

152class AlexaMediaPlayerTransport(Transport): 

153 """Notify via Amazon Alexa announcements with full volume management. 

154 

155 options: 

156 message_usage: standard | use_title | combine_title | message_only 

157 media_auto_pause: bool, sets the default for pausing music/restoring volume 

158 """ 

159 

160 name = TRANSPORT_ALEXA_MEDIA_PLAYER 

161 integration_domain = HA_ALEXA_MEDIA_PLAYER_PLATFORM 

162 action_configured = True 

163 declared_options: ClassVar[list[DeliveryOption]] = [ 

164 DeliveryOption( 

165 OPTION_MEDIA_AUTO_PAUSE, 

166 "Pause (rather than stop) music if playing before announcing, and restore afterwards", 

167 value_type=cv.boolean, 

168 ), 

169 ] 

170 

171 def __init__(self, *args: Any, **kwargs: Any) -> None: 

172 super().__init__(*args, **kwargs) 

173 

174 @property 

175 def supported_features(self) -> TransportFeature: 

176 return TransportFeature.MESSAGE | TransportFeature.SPOKEN 

177 

178 @property 

179 def default_config(self) -> TransportConfig: 

180 config = super().default_config 

181 config.delivery_defaults.action = self.hass_api.find_service("notify", HA_ALEXA_MEDIA_PLAYER_MODULE) 

182 config.delivery_defaults.options = { 

183 OPTION_SIMPLIFY_TEXT: True, 

184 OPTION_STRIP_URLS: True, 

185 OPTION_MESSAGE_USAGE: MessageOnlyPolicy.STANDARD, 

186 OPTION_TARGET_SELECT: [RE_MEDIA_PLAYER_ENTITY_ID], 

187 OPTION_MEDIA_AUTO_PAUSE: True, 

188 } 

189 return config 

190 

191 @property 

192 def other_target_categories(self) -> list[str | TargetEntityCategory]: 

193 # platform-scoped, but still just "a media_player entity" - doesn't say whether the 

194 # intent was audio/TTS/a chime, so this stays `other`, not `unique`, like the rest of 

195 # the media_player-domain transports (kodi/tts/the `media` transport) 

196 return [TargetEntityCategory(domain="media_player", platform=HA_ALEXA_MEDIA_PLAYER_PLATFORM)] 

197 

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

199 return action is not None 

200 

201 async def _safe_service( 

202 self, domain: str, service: str, service_data: dict[str, Any], context: HAContext | None = None 

203 ) -> bool: 

204 """Call a HA service via hass_api, catching exceptions so offline devices never block overall delivery.""" 

205 try: 

206 await self.hass_api.call_service(domain, service, service_data=service_data, context=context) 

207 return True 

208 except Exception as exc: 

209 _LOGGER.debug( 

210 "SUPERNOTIFY alexa_media_player: %s.%s failed for %s: %s", 

211 domain, 

212 service, 

213 service_data.get(ATTR_ENTITY_ID, "unknown"), 

214 exc, 

215 ) 

216 return False 

217 

218 async def _snapshot_states(self, media_players: list[str], volume_fallback: float) -> dict[str, dict[str, Any]]: 

219 """Read volume and playback state for every target. 

220 

221 Uses volume_fallback when volume_level is None (AMP issue #1394). 

222 """ 

223 states: dict[str, dict[str, Any]] = {} 

224 for mp in media_players: 

225 state = self.hass_api.get_state(mp) 

226 if state is None: 

227 _LOGGER.debug("SUPERNOTIFY alexa_media_player: %s not found", mp) 

228 continue 

229 vol = state.attributes.get("volume_level") 

230 if vol is None: 

231 _LOGGER.debug( 

232 "SUPERNOTIFY alexa_media_player: %s volume_level None, using fallback %.2f (AMP issue #1394)", 

233 mp, 

234 volume_fallback, 

235 ) 

236 vol = volume_fallback 

237 states[mp] = {"volume": float(vol), "playing": state.state == "playing"} 

238 return states 

239 

240 async def _pre_announce( 

241 self, 

242 states: dict[str, dict[str, Any]], 

243 requested_volume: float, 

244 pause_music: bool, 

245 context: HAContext | None = None, 

246 ) -> set[str]: 

247 """Pause music, stop beep, set announcement volume. Runs per-device concurrently since 

248 each Alexa cloud round trip can take seconds, and serializing across targets stacks 

249 that latency instead of overlapping it.""" 

250 

251 async def handle(mp: str, prev: dict[str, Any]) -> tuple[str, bool]: 

252 if prev["playing"]: 

253 if pause_music: 

254 # Pause only — do NOT also call media_stop. 

255 # media_stop after media_pause kills streaming sessions 

256 # (Spotify, etc.) making them impossible to resume later. 

257 # media_pause leaves the session alive for media_play resume. 

258 await self._safe_service("media_player", "media_pause", {ATTR_ENTITY_ID: mp}, context=context) 

259 else: 

260 # Not pausing: use media_stop to suppress the Alexa 

261 # confirmation beep before volume_set (no resume expected). 

262 await self._safe_service("media_player", "media_stop", {ATTR_ENTITY_ID: mp}, context=context) 

263 ok = await self._safe_service( 

264 "media_player", 

265 "volume_set", 

266 {ATTR_ENTITY_ID: mp, "volume_level": requested_volume}, 

267 context=context, 

268 ) 

269 return mp, ok 

270 

271 results = await asyncio.gather(*(handle(mp, prev) for mp, prev in states.items())) 

272 return {mp for mp, ok in results if not ok} 

273 

274 async def _post_announce( 

275 self, 

276 states: dict[str, dict[str, Any]], 

277 restore_volume: bool, 

278 pause_music: bool, 

279 context: HAContext | None = None, 

280 ) -> None: 

281 """Restore volume and resume music after announcement, per-device concurrently.""" 

282 music_devices = [mp for mp, s in states.items() if pause_music and s["playing"]] 

283 if restore_volume: 

284 # Do NOT call media_stop here: after TTS finishes Alexa is already 

285 # idle, so media_stop would produce an unwanted confirmation beep. 

286 await asyncio.gather( 

287 *( 

288 self._safe_service( 

289 "media_player", 

290 "volume_set", 

291 {ATTR_ENTITY_ID: mp, "volume_level": prev["volume"]}, 

292 context=context, 

293 ) 

294 for mp, prev in states.items() 

295 ) 

296 ) 

297 if music_devices: 

298 await sleep(_MUSIC_RESUME_DELAY) 

299 await asyncio.gather( 

300 *( 

301 self._safe_service("media_player", "media_play", {ATTR_ENTITY_ID: mp}, context=context) 

302 for mp in music_devices 

303 ) 

304 ) 

305 

306 def _audio_ssml(self, audio_url: str, message: str | None) -> str: 

307 """Wrap an audio clip, and the message spoken after it, in SSML for Alexa.""" 

308 url = urllib.parse.urljoin(self.hass_api.external_url or "", audio_url) 

309 if not url.startswith("https://"): 

310 _LOGGER.warning( 

311 "SUPERNOTIFY alexa_media_player: audio_url %s is not https, Alexa will refuse to play it", 

312 url, 

313 ) 

314 spoken = html.escape(message, quote=False) if message else "" 

315 return f'<speak><audio src="{html.escape(url, quote=True)}"/>{spoken}</speak>' 

316 

317 async def deliver( 

318 self, 

319 envelope: Envelope, 

320 debug_trace: DebugTrace | None = None, 

321 ) -> bool: 

322 _LOGGER.debug("SUPERNOTIFY notify_alexa_media %s", envelope.message) 

323 

324 media_players = envelope.target.entity_ids or [] 

325 if not media_players: 

326 _LOGGER.debug("SUPERNOTIFY Skipping alexa media player, no targets") 

327 return False 

328 

329 # envelope.data is a flat dict — keys like volume, type, method 

330 # are at the top level, not nested under a "data" key. 

331 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {} 

332 

333 volume_raw = raw_data.pop("volume", None) 

334 restore_volume: bool = boolify(raw_data.pop("restore_volume", True), default=True) 

335 pause_music: bool = boolify(raw_data.pop("pause_music", True), default=True) 

336 volume_fallback: float = float(raw_data.pop("volume_fallback", 0.5)) 

337 wait_for_tts: bool = boolify(raw_data.pop("wait_for_tts", False), default=False) 

338 tts_char_speed: float = float(raw_data.pop("tts_char_speed", _CHAR_WEIGHT)) 

339 audio_url: str | None = raw_data.pop("audio_url", None) 

340 audio_duration: float = float(raw_data.pop("audio_duration", 0) or 0) 

341 

342 message: str | None = envelope.message 

343 if audio_url: 

344 message = self._audio_ssml(audio_url, message) 

345 

346 auto_pause = envelope.delivery.option_bool(OPTION_MEDIA_AUTO_PAUSE, True) 

347 

348 # a template for volume has already been rendered by the envelope 

349 requested_volume: float | None = None 

350 if volume_raw is not None: 

351 try: 

352 requested_volume = float(volume_raw) 

353 except (TypeError, ValueError) as e: # py3.13 compat 

354 _LOGGER.warning("SUPERNOTIFY alexa_media_player: invalid volume value %r, ignoring: %s", volume_raw, e) 

355 

356 states: dict[str, dict[str, Any]] = {} 

357 volume_set_failed: set[str] = set() 

358 

359 if auto_pause: 

360 # Pre-announce 

361 if requested_volume is not None or pause_music: 

362 states = await self._snapshot_states(media_players, volume_fallback) 

363 

364 if requested_volume is not None and states: 

365 volume_set_failed = await self._pre_announce(states, requested_volume, pause_music, context=envelope.ha_context) 

366 elif pause_music and states: 

367 await asyncio.gather( 

368 *( 

369 self._safe_service("media_player", "media_pause", {ATTR_ENTITY_ID: mp}, context=envelope.ha_context) 

370 for mp, prev in states.items() 

371 if prev["playing"] 

372 ) 

373 ) 

374 elif requested_volume is not None: 

375 _LOGGER.debug("SUPERNOTIFY alexa_media_player: volume ignored, %s is off", OPTION_MEDIA_AUTO_PAUSE) 

376 

377 # needs_post_announce is True only when there is something to undo (a volume to restore 

378 # or music that was paused), so an announcement with neither doesn't wait for the TTS; 

379 # computed here, before the announce, so it's available in the finally block below 

380 # regardless of what happens during the announce/TTS wait. 

381 restore_volume = restore_volume and requested_volume is not None 

382 needs_post_announce = bool(states) and ( 

383 restore_volume or (pause_music and any(prev["playing"] for prev in states.values())) 

384 ) 

385 

386 # Announce 

387 call_type: str = raw_data.pop("type", "announce") 

388 if audio_url and call_type != "tts": 

389 # Alexa stays silent for SSML <audio> in announce mode (checked on a real Echo), 

390 # so a delivery-level `type: announce` default must not win here 

391 _LOGGER.debug("SUPERNOTIFY alexa_media_player: audio_url forces type tts, was %s", call_type) 

392 call_type = "tts" 

393 action_data: dict[str, Any] = { 

394 "message": message, 

395 ATTR_DATA: {"type": call_type}, 

396 ATTR_TARGET: media_players, 

397 } 

398 if raw_data.get("method"): 

399 action_data[ATTR_DATA]["method"] = raw_data.pop("method") 

400 if requested_volume is not None and auto_pause and volume_set_failed: 

401 # Fallback path: if pre-announce volume_set fails for one or more 

402 # players, pass volume through notify.alexa_media too. 

403 action_data[ATTR_DATA]["volume"] = requested_volume 

404 

405 result = False 

406 try: 

407 result = await self.call_action(envelope, action_data=action_data) 

408 

409 # Post-announce wait: optionally wait for TTS before restoring volume / resuming 

410 # music. wait_for_tts additionally blocks even in pure fire-and-forget deliveries, 

411 # allowing automation sequences to run only after the announcement ends, and does 

412 # so regardless of auto_pause since it has nothing to do with restoring state. 

413 if result and (needs_post_announce or wait_for_tts) and message: 

414 tts_duration = _estimate_tts_duration(message, tts_char_speed) + audio_duration 

415 _LOGGER.debug( 

416 "SUPERNOTIFY alexa_media_player: waiting %.1f s for TTS (%d chars, %.3f s/ch, %.1f s audio)", 

417 tts_duration, 

418 len(RE_SSML_TAG.sub("", message)), 

419 tts_char_speed, 

420 audio_duration, 

421 ) 

422 await sleep(tts_duration) 

423 finally: 

424 # Restore/resume must run even if the announce call or TTS wait raises or is 

425 # cancelled (e.g. HA shutting down), otherwise a pre-announce pause/volume change 

426 # made above is never undone and the device is stuck paused/at announce volume. 

427 if needs_post_announce: 

428 await self._post_announce(states, restore_volume, pause_music, context=envelope.ha_context) 

429 

430 return result