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

165 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 21:14 +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 no volume/music 

18 restore is needed (fire-and-forget mode). 

19 Duration calibrated per-language via tts_char_speed. 

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

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

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

23 TTS, calling media_stop would produce another unwanted beep. 

24 

25Data keys (all optional): 

26 volume float 0-1 desired announcement volume 

27 restore_volume bool restore previous volume (default True) 

28 pause_music bool pause music if playing (default True) 

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

30 wait_for_tts bool block until TTS finishes before returning. 

31 Default False (fire-and-forget). 

32 Set True to sequence automation actions after 

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

34 Alexa has finished speaking"). 

35 When volume/music restore is active this wait 

36 happens implicitly; wait_for_tts=True only adds 

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

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

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

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

41 external URL. Amazon fetches the file from its own 

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

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

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

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

46 after the audio as plain text. 

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

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

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

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

51 Default 0.06 (Italian/English calibration). 

52 Suggested values by language family: 

53 Italian / English / French : 0.060 

54 Spanish / Portuguese : 0.058 

55 German : 0.065 

56 Russian / Polish : 0.062 

57 Japanese / Chinese / Korean : 0.180 

58 Arabic : 0.075 

59 

60 

61References: 

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

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

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

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

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

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

68 

69""" 

70 

71from __future__ import annotations 

72 

73import asyncio 

74import html 

75import logging 

76import re 

77import urllib.parse 

78from typing import TYPE_CHECKING, Any, ClassVar, cast 

79 

80from homeassistant.components.notify.const import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, ATTR_TITLE 

81from homeassistant.const import ( 

82 ATTR_ENTITY_ID, 

83) 

84from homeassistant.helpers import config_validation as cv 

85from homeassistant.helpers.typing import ConfigType 

86 

87from custom_components.supernotify.common import boolify 

88from custom_components.supernotify.const import ( 

89 RE_MEDIA_PLAYER_ENTITY_ID, 

90 TRANSPORT_ALEXA_MEDIA_PLAYER, 

91) 

92from custom_components.supernotify.model import ( 

93 DebugTrace, 

94 MessageOnlyPolicy, 

95 TargetRequired, 

96 TransportConfig, 

97 TransportFeature, 

98) 

99from custom_components.supernotify.options import ( 

100 OPTION_MESSAGE_USAGE, 

101 OPTION_SIMPLIFY_TEXT, 

102 OPTION_STRIP_URLS, 

103 OPTION_TARGET_SELECT, 

104 OPTION_UNIQUE_TARGETS, 

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 from custom_components.supernotify.hass_api import HomeAssistantAPI 

115 

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

117HA_ALEXA_MEDIA_PLAYER_MODULE = "custom_components.alexa_media.notify" 

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

119HA_ALEXA_MEDIA_PLAYER_PLATFORM = "alexa_media" 

120 

121 

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

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

124 

125# ref: https://github.com/alandtse/alexa_media_player/wiki/Configuration%3A-Notification-Component 

126SERVICE_DATA_KEYS = [ATTR_MESSAGE, ATTR_TITLE, ATTR_DATA, ATTR_TARGET] 

127SERVICE_DATA_DATA_KEYS = ["type", "method"] 

128 

129_PAUSE_WEIGHT = 0.35 

130_CHAR_WEIGHT = 0.06 

131_BASE_DURATION = 5.0 

132_MUSIC_RESUME_DELAY = 2.0 

133 

134_LOGGER = logging.getLogger(__name__) 

135 

136OPTION_MEDIA_AUTO_PAUSE = "media_auto_pause" 

137 

138 

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

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

141 

142 Formula from energywave/multinotify: 

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

144 

145 Args: 

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

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

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

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

150 

151 """ 

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

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

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

155 

156 

157class AlexaMediaPlayerTransport(Transport): 

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

159 

160 options: 

161 message_usage: standard | use_title | combine_title 

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

163 """ 

164 

165 name = TRANSPORT_ALEXA_MEDIA_PLAYER 

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

167 DeliveryOption( 

168 OPTION_MEDIA_AUTO_PAUSE, 

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

170 value_type=cv.boolean, 

171 ), 

172 ] 

173 

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

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

176 

177 @property 

178 def supported_features(self) -> TransportFeature: 

179 return TransportFeature.MESSAGE | TransportFeature.SPOKEN 

180 

181 @property 

182 def default_config(self) -> TransportConfig: 

183 config = TransportConfig() 

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

185 config.delivery_defaults.target_required = TargetRequired.ALWAYS 

186 config.delivery_defaults.inclusion = self.inclusion_mode 

187 config.delivery_defaults.options = { 

188 OPTION_SIMPLIFY_TEXT: True, 

189 OPTION_STRIP_URLS: True, 

190 OPTION_MESSAGE_USAGE: MessageOnlyPolicy.STANDARD, 

191 OPTION_UNIQUE_TARGETS: True, 

192 OPTION_TARGET_SELECT: [RE_MEDIA_PLAYER_ENTITY_ID], 

193 OPTION_MEDIA_AUTO_PAUSE: True, 

194 } 

195 return config 

196 

197 @property 

198 def target_categories(self) -> list[str | TargetEntityCategory]: 

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

200 

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

202 return action is not None 

203 

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

205 # like validate_action() above, an explicit delivery can supply its own action 

206 # regardless of whether the service is discoverable here - is_viable() can't see 

207 # delivery-level config, so it can't rule that out; DeliveryRegistry prunes this 

208 # transport entirely once it's confirmed no delivery (explicit or auto) uses it 

209 return True 

210 

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

212 if self.delivery_defaults.action: 

213 return {self.name: {}} 

214 return {} 

215 

216 async def _safe_service( 

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

218 ) -> bool: 

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

220 try: 

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

222 return True 

223 except Exception as exc: 

224 _LOGGER.debug( 

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

226 domain, 

227 service, 

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

229 exc, 

230 ) 

231 return False 

232 

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

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

235 

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

237 """ 

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

239 for mp in media_players: 

240 state = self.hass_api.get_state(mp) 

241 if state is None: 

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

243 continue 

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

245 if vol is None: 

246 _LOGGER.debug( 

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

248 mp, 

249 volume_fallback, 

250 ) 

251 vol = volume_fallback 

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

253 return states 

254 

255 async def _pre_announce( 

256 self, 

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

258 requested_volume: float, 

259 pause_music: bool, 

260 context: HAContext | None = None, 

261 ) -> set[str]: 

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

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

264 that latency instead of overlapping it.""" 

265 

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

267 if prev["playing"]: 

268 if pause_music: 

269 # Pause only — do NOT also call media_stop. 

270 # media_stop after media_pause kills streaming sessions 

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

272 # media_pause leaves the session alive for media_play resume. 

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

274 else: 

275 # Not pausing: use media_stop to suppress the Alexa 

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

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

278 ok = await self._safe_service( 

279 "media_player", 

280 "volume_set", 

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

282 context=context, 

283 ) 

284 return mp, ok 

285 

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

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

288 

289 async def _post_announce( 

290 self, 

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

292 restore_volume: bool, 

293 pause_music: bool, 

294 context: HAContext | None = None, 

295 ) -> None: 

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

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

298 if restore_volume: 

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

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

301 await asyncio.gather( 

302 *( 

303 self._safe_service( 

304 "media_player", 

305 "volume_set", 

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

307 context=context, 

308 ) 

309 for mp, prev in states.items() 

310 ) 

311 ) 

312 if music_devices: 

313 await asyncio.sleep(_MUSIC_RESUME_DELAY) 

314 await asyncio.gather( 

315 *( 

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

317 for mp in music_devices 

318 ) 

319 ) 

320 

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

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

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

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

325 _LOGGER.warning( 

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

327 url, 

328 ) 

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

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

331 

332 async def deliver( 

333 self, 

334 envelope: Envelope, 

335 debug_trace: DebugTrace | None = None, 

336 ) -> bool: 

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

338 

339 media_players = envelope.target.entity_ids or [] 

340 if not media_players: 

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

342 return False 

343 

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

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

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

347 

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

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

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

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

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

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

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

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

356 

357 message: str | None = envelope.message 

358 if audio_url: 

359 message = self._audio_ssml(audio_url, message) 

360 

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

362 

363 # Resolve Jinja2 template if volume is still a raw template string 

364 # (scenarios store volume as a template; _resolve_data_templates only 

365 # runs for archiving, not for delivery). 

366 requested_volume: float | None = None 

367 if isinstance(volume_raw, str) and "{{" in volume_raw: 

368 try: 

369 context_vars = ( 

370 cast("dict[str, Any]", envelope.condition_variables.as_dict()) if envelope.condition_variables else {} 

371 ) 

372 rendered = self.hass_api.template(volume_raw).async_render(variables=context_vars) 

373 requested_volume = float(rendered) 

374 _LOGGER.debug("SUPERNOTIFY alexa_media_player: resolved volume template to %.2f", requested_volume) 

375 except Exception as exc: 

376 _LOGGER.warning("SUPERNOTIFY alexa_media_player: failed to resolve volume template %r: %s", volume_raw, exc) 

377 elif volume_raw is not None: 

378 try: 

379 requested_volume = float(volume_raw) 

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

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

382 

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

384 needs_restore = False 

385 volume_set_failed: set[str] = set() 

386 

387 if auto_pause: 

388 # Pre-announce 

389 needs_restore = requested_volume is not None or pause_music 

390 

391 if needs_restore: 

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

393 

394 if requested_volume is not None and states: 

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

396 elif pause_music and states: 

397 await asyncio.gather( 

398 *( 

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

400 for mp, prev in states.items() 

401 if prev["playing"] 

402 ) 

403 ) 

404 

405 # needs_post_announce is True whenever there is something to undo (volume change or 

406 # music was paused); computed here, before the announce, so it's available in the 

407 # finally block below regardless of what happens during the announce/TTS wait. 

408 needs_post_announce = needs_restore and bool(states) 

409 

410 # Announce 

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

412 if audio_url and call_type != "tts": 

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

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

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

416 call_type = "tts" 

417 action_data: dict[str, Any] = { 

418 "message": message, 

419 ATTR_DATA: {"type": call_type}, 

420 ATTR_TARGET: media_players, 

421 } 

422 if requested_volume is not None and auto_pause and volume_set_failed: 

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

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

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

426 

427 result = False 

428 try: 

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

430 

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

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

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

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

435 if (needs_post_announce or wait_for_tts) and message: 

436 tts_duration = _estimate_tts_duration(message, tts_char_speed) + audio_duration 

437 _LOGGER.debug( 

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

439 tts_duration, 

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

441 tts_char_speed, 

442 audio_duration, 

443 ) 

444 await asyncio.sleep(tts_duration) 

445 finally: 

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

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

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

449 if needs_post_announce: 

450 await self._post_announce( 

451 states, restore_volume and requested_volume is not None, pause_music, context=envelope.ha_context 

452 ) 

453 

454 return result