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

130 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-04-01 15:06 +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 

48References: 

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

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

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

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

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

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

55 

56""" 

57 

58import asyncio 

59import logging 

60import re 

61from typing import TYPE_CHECKING, Any, cast 

62 

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

64from homeassistant.const import ATTR_ENTITY_ID 

65 

66from custom_components.supernotify.common import boolify 

67from custom_components.supernotify.const import ( 

68 OPTION_MESSAGE_USAGE, 

69 OPTION_SIMPLIFY_TEXT, 

70 OPTION_STRIP_URLS, 

71 OPTION_TARGET_CATEGORIES, 

72 OPTION_TARGET_SELECT, 

73 OPTION_UNIQUE_TARGETS, 

74 TRANSPORT_ALEXA_MEDIA_PLAYER, 

75) 

76from custom_components.supernotify.model import ( 

77 DebugTrace, 

78 MessageOnlyPolicy, 

79 TargetRequired, 

80 TransportConfig, 

81 TransportFeature, 

82) 

83from custom_components.supernotify.transport import Transport 

84 

85if TYPE_CHECKING: 

86 from custom_components.supernotify.envelope import Envelope 

87 

88RE_VALID_ALEXA = r"media_player\.[A-Za-z0-9_]+" 

89 

90 

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

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

93 

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

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

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

97 

98_PAUSE_WEIGHT = 0.35 

99_CHAR_WEIGHT = 0.06 

100_BASE_DURATION = 5.0 

101_MUSIC_RESUME_DELAY = 2.0 

102 

103_LOGGER = logging.getLogger(__name__) 

104 

105 

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

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

108 

109 Formula from energywave/multinotify: 

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

111 

112 Args: 

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

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

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

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

117 

118 """ 

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

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

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

122 

123 

124class AlexaMediaPlayerTransport(Transport): 

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

126 

127 options: 

128 message_usage: standard | use_title | combine_title 

129 """ 

130 

131 name = TRANSPORT_ALEXA_MEDIA_PLAYER 

132 

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

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

135 

136 @property 

137 def supported_features(self) -> TransportFeature: 

138 return TransportFeature.MESSAGE | TransportFeature.SPOKEN 

139 

140 @property 

141 def default_config(self) -> TransportConfig: 

142 config = TransportConfig() 

143 config.delivery_defaults.action = "notify.alexa_media" 

144 config.delivery_defaults.target_required = TargetRequired.ALWAYS 

145 config.delivery_defaults.options = { 

146 OPTION_SIMPLIFY_TEXT: True, 

147 OPTION_STRIP_URLS: True, 

148 OPTION_MESSAGE_USAGE: MessageOnlyPolicy.STANDARD, 

149 OPTION_UNIQUE_TARGETS: True, 

150 OPTION_TARGET_CATEGORIES: [ATTR_ENTITY_ID], 

151 OPTION_TARGET_SELECT: [RE_VALID_ALEXA], 

152 } 

153 return config 

154 

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

156 return action is not None 

157 

158 async def _safe_service(self, domain: str, service: str, service_data: dict[str, Any]) -> bool: 

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

160 try: 

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

162 return True 

163 except Exception as exc: 

164 _LOGGER.debug( 

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

166 domain, 

167 service, 

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

169 exc, 

170 ) 

171 return False 

172 

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

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

175 

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

177 """ 

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

179 for mp in media_players: 

180 state = self.hass_api.get_state(mp) 

181 if state is None: 

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

183 continue 

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

185 if vol is None: 

186 _LOGGER.debug( 

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

188 mp, 

189 volume_fallback, 

190 ) 

191 vol = volume_fallback 

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

193 return states 

194 

195 async def _pre_announce( 

196 self, 

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

198 requested_volume: float, 

199 pause_music: bool, 

200 ) -> set[str]: 

201 """Pause music, stop beep, set announcement volume.""" 

202 volume_set_failed: set[str] = set() 

203 for mp, prev in states.items(): 

204 if prev["playing"]: 

205 if pause_music: 

206 # Pause only — do NOT also call media_stop. 

207 # media_stop after media_pause kills streaming sessions 

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

209 # media_pause leaves the session alive for media_play resume. 

210 await self._safe_service("media_player", "media_pause", {ATTR_ENTITY_ID: mp}) 

211 else: 

212 # Not pausing: use media_stop to suppress the Alexa 

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

214 await self._safe_service("media_player", "media_stop", {ATTR_ENTITY_ID: mp}) 

215 if not await self._safe_service( 

216 "media_player", 

217 "volume_set", 

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

219 ): 

220 volume_set_failed.add(mp) 

221 return volume_set_failed 

222 

223 async def _post_announce( 

224 self, 

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

226 restore_volume: bool, 

227 pause_music: bool, 

228 ) -> None: 

229 """Restore volume and resume music after announcement.""" 

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

231 for mp, prev in states.items(): 

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

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

234 if restore_volume: 

235 await self._safe_service( 

236 "media_player", 

237 "volume_set", 

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

239 ) 

240 if music_devices: 

241 await asyncio.sleep(_MUSIC_RESUME_DELAY) 

242 for mp in music_devices: 

243 await self._safe_service("media_player", "media_play", {ATTR_ENTITY_ID: mp}) 

244 

245 async def deliver( 

246 self, 

247 envelope: Envelope, 

248 debug_trace: DebugTrace | None = None, # noqa: ARG002 

249 ) -> bool: 

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

251 

252 media_players = envelope.target.entity_ids or [] 

253 if not media_players: 

254 _LOGGER.debug("SUPERNOTIFY skipping alexa media player, no targets") 

255 return False 

256 

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

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

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

260 

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

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

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

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

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

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

267 

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

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

270 # runs for archiving, not for delivery). 

271 requested_volume: float | None = None 

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

273 try: 

274 context_vars = ( 

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

276 ) 

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

278 requested_volume = float(rendered) 

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

280 except Exception as exc: 

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

282 elif volume_raw is not None: 

283 try: 

284 requested_volume = float(volume_raw) 

285 except TypeError, ValueError: 

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

287 

288 # Pre-announce 

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

290 needs_restore = requested_volume is not None or pause_music 

291 

292 if needs_restore: 

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

294 

295 volume_set_failed: set[str] = set() 

296 if requested_volume is not None and states: 

297 volume_set_failed = await self._pre_announce(states, requested_volume, pause_music) 

298 elif pause_music and states: 

299 for mp, prev in states.items(): 

300 if prev["playing"]: 

301 await self._safe_service("media_player", "media_pause", {ATTR_ENTITY_ID: mp}) 

302 

303 # Announce 

304 call_type: str = raw_data.get(ATTR_DATA, {}).get("type", "announce") 

305 action_data: dict[str, Any] = { 

306 "message": envelope.message, 

307 ATTR_DATA: {"type": call_type}, 

308 ATTR_TARGET: media_players, 

309 } 

310 if requested_volume is not None and volume_set_failed: 

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

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

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

314 

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

316 

317 # Post-announce: optionally wait for TTS, then restore volume / resume music. 

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

319 # or music was paused); in that case the TTS wait is always performed so the 

320 # restore/resume happens after Alexa finishes speaking. 

321 # wait_for_tts additionally blocks even in pure fire-and-forget deliveries, 

322 # allowing automation sequences to run only after the announcement ends. 

323 needs_post_announce = needs_restore and bool(states) 

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

325 tts_duration = _estimate_tts_duration(envelope.message, tts_char_speed) 

326 _LOGGER.debug( 

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

328 tts_duration, 

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

330 tts_char_speed, 

331 ) 

332 await asyncio.sleep(tts_duration) 

333 if needs_post_announce: 

334 await self._post_announce(states, restore_volume and requested_volume is not None, pause_music) 

335 

336 return result