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

149 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 14:29 +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 tts_char_speed float s/ch seconds per character for TTS duration estimate. 

39 Default 0.06 (Italian/English calibration). 

40 Suggested values by language family: 

41 Italian / English / French : 0.060 

42 Spanish / Portuguese : 0.058 

43 German : 0.065 

44 Russian / Polish : 0.062 

45 Japanese / Chinese / Korean : 0.180 

46 Arabic : 0.075 

47 

48 

49References: 

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

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

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

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

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

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

56 

57""" 

58 

59from __future__ import annotations 

60 

61import asyncio 

62import logging 

63import re 

64from typing import TYPE_CHECKING, Any, ClassVar, cast 

65 

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

67from homeassistant.const import ( 

68 ATTR_ENTITY_ID, 

69) 

70from homeassistant.helpers import config_validation as cv 

71from homeassistant.helpers.typing import ConfigType 

72 

73from custom_components.supernotify.common import boolify 

74from custom_components.supernotify.const import ( 

75 RE_MEDIA_PLAYER_ENTITY_ID, 

76 TRANSPORT_ALEXA_MEDIA_PLAYER, 

77) 

78from custom_components.supernotify.model import ( 

79 DebugTrace, 

80 MessageOnlyPolicy, 

81 TargetRequired, 

82 TransportConfig, 

83 TransportFeature, 

84) 

85from custom_components.supernotify.options import ( 

86 OPTION_MESSAGE_USAGE, 

87 OPTION_SIMPLIFY_TEXT, 

88 OPTION_STRIP_URLS, 

89 OPTION_TARGET_SELECT, 

90 OPTION_UNIQUE_TARGETS, 

91 DeliveryOption, 

92) 

93from custom_components.supernotify.target import TargetEntityCategory 

94from custom_components.supernotify.transport import Transport 

95 

96if TYPE_CHECKING: 

97 from homeassistant.core import Context as HAContext 

98 

99 from custom_components.supernotify.envelope import Envelope 

100 from custom_components.supernotify.hass_api import HomeAssistantAPI 

101 

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

103HA_ALEXA_MEDIA_PLAYER_MODULE = "custom_components.alexa_media.notify" 

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

105HA_ALEXA_MEDIA_PLAYER_PLATFORM = "alexa_media" 

106 

107 

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

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

110 

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

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

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

114 

115_PAUSE_WEIGHT = 0.35 

116_CHAR_WEIGHT = 0.06 

117_BASE_DURATION = 5.0 

118_MUSIC_RESUME_DELAY = 2.0 

119 

120_LOGGER = logging.getLogger(__name__) 

121 

122OPTION_MEDIA_AUTO_PAUSE = "media_auto_pause" 

123 

124 

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

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

127 

128 Formula from energywave/multinotify: 

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

130 

131 Args: 

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

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

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

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

136 

137 """ 

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

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

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

141 

142 

143class AlexaMediaPlayerTransport(Transport): 

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

145 

146 options: 

147 message_usage: standard | use_title | combine_title 

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

149 """ 

150 

151 name = TRANSPORT_ALEXA_MEDIA_PLAYER 

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

153 DeliveryOption( 

154 OPTION_MEDIA_AUTO_PAUSE, 

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

156 value_type=cv.boolean, 

157 ), 

158 ] 

159 

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

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

162 

163 @property 

164 def supported_features(self) -> TransportFeature: 

165 return TransportFeature.MESSAGE | TransportFeature.SPOKEN 

166 

167 @property 

168 def default_config(self) -> TransportConfig: 

169 config = TransportConfig() 

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

171 config.delivery_defaults.target_required = TargetRequired.ALWAYS 

172 config.delivery_defaults.inclusion = self.inclusion_mode 

173 config.delivery_defaults.options = { 

174 OPTION_SIMPLIFY_TEXT: True, 

175 OPTION_STRIP_URLS: True, 

176 OPTION_MESSAGE_USAGE: MessageOnlyPolicy.STANDARD, 

177 OPTION_UNIQUE_TARGETS: True, 

178 OPTION_TARGET_SELECT: [RE_MEDIA_PLAYER_ENTITY_ID], 

179 OPTION_MEDIA_AUTO_PAUSE: True, 

180 } 

181 return config 

182 

183 @property 

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

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

186 

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

188 return action is not None 

189 

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

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

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

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

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

195 return True 

196 

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

198 if self.delivery_defaults.action: 

199 return {self.name: {}} 

200 return {} 

201 

202 async def _safe_service( 

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

204 ) -> bool: 

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

206 try: 

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

208 return True 

209 except Exception as exc: 

210 _LOGGER.debug( 

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

212 domain, 

213 service, 

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

215 exc, 

216 ) 

217 return False 

218 

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

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

221 

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

223 """ 

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

225 for mp in media_players: 

226 state = self.hass_api.get_state(mp) 

227 if state is None: 

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

229 continue 

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

231 if vol is None: 

232 _LOGGER.debug( 

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

234 mp, 

235 volume_fallback, 

236 ) 

237 vol = volume_fallback 

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

239 return states 

240 

241 async def _pre_announce( 

242 self, 

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

244 requested_volume: float, 

245 pause_music: bool, 

246 context: HAContext | None = None, 

247 ) -> set[str]: 

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

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

250 that latency instead of overlapping it.""" 

251 

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

253 if prev["playing"]: 

254 if pause_music: 

255 # Pause only — do NOT also call media_stop. 

256 # media_stop after media_pause kills streaming sessions 

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

258 # media_pause leaves the session alive for media_play resume. 

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

260 else: 

261 # Not pausing: use media_stop to suppress the Alexa 

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

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

264 ok = await self._safe_service( 

265 "media_player", 

266 "volume_set", 

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

268 context=context, 

269 ) 

270 return mp, ok 

271 

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

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

274 

275 async def _post_announce( 

276 self, 

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

278 restore_volume: bool, 

279 pause_music: bool, 

280 context: HAContext | None = None, 

281 ) -> None: 

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

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

284 if restore_volume: 

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

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

287 await asyncio.gather( 

288 *( 

289 self._safe_service( 

290 "media_player", 

291 "volume_set", 

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

293 context=context, 

294 ) 

295 for mp, prev in states.items() 

296 ) 

297 ) 

298 if music_devices: 

299 await asyncio.sleep(_MUSIC_RESUME_DELAY) 

300 await asyncio.gather( 

301 *( 

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

303 for mp in music_devices 

304 ) 

305 ) 

306 

307 async def deliver( 

308 self, 

309 envelope: Envelope, 

310 debug_trace: DebugTrace | None = None, 

311 ) -> bool: 

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

313 

314 media_players = envelope.target.entity_ids or [] 

315 if not media_players: 

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

317 return False 

318 

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

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

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

322 

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

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

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

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

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

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

329 

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

331 

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

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

334 # runs for archiving, not for delivery). 

335 requested_volume: float | None = None 

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

337 try: 

338 context_vars = ( 

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

340 ) 

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

342 requested_volume = float(rendered) 

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

344 except Exception as exc: 

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

346 elif volume_raw is not None: 

347 try: 

348 requested_volume = float(volume_raw) 

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

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

351 

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

353 needs_restore = False 

354 volume_set_failed: set[str] = set() 

355 

356 if auto_pause: 

357 # Pre-announce 

358 needs_restore = requested_volume is not None or pause_music 

359 

360 if needs_restore: 

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

362 

363 if requested_volume is not None and states: 

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

365 elif pause_music and states: 

366 await asyncio.gather( 

367 *( 

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

369 for mp, prev in states.items() 

370 if prev["playing"] 

371 ) 

372 ) 

373 

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

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

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

377 needs_post_announce = needs_restore and bool(states) 

378 

379 # Announce 

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

381 action_data: dict[str, Any] = { 

382 "message": envelope.message, 

383 ATTR_DATA: {"type": call_type}, 

384 ATTR_TARGET: media_players, 

385 } 

386 if requested_volume is not None and auto_pause and volume_set_failed: 

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

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

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

390 

391 result = False 

392 try: 

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

394 

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

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

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

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

399 if (needs_post_announce or wait_for_tts) and envelope.message: 

400 tts_duration = _estimate_tts_duration(envelope.message, tts_char_speed) 

401 _LOGGER.debug( 

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

403 tts_duration, 

404 len(RE_SSML_TAG.sub("", envelope.message)), 

405 tts_char_speed, 

406 ) 

407 await asyncio.sleep(tts_duration) 

408 finally: 

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

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

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

412 if needs_post_announce: 

413 await self._post_announce( 

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

415 ) 

416 

417 return result