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
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 21:14 +0000
1"""Alexa Media Player transport adaptor for Supernotify.
3Volume management: Amazon Alexa API does not expose a per-announcement
4volume parameter in notify.alexa_media. This adaptor handles it natively:
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.
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
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
69"""
71from __future__ import annotations
73import asyncio
74import html
75import logging
76import re
77import urllib.parse
78from typing import TYPE_CHECKING, Any, ClassVar, cast
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
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
110if TYPE_CHECKING:
111 from homeassistant.core import Context as HAContext
113 from custom_components.supernotify.envelope import Envelope
114 from custom_components.supernotify.hass_api import HomeAssistantAPI
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"
122RE_SSML_TAG = re.compile(r"<[^>]+>")
123PAUSE_CHARS = (", ", ". ", "! ", "? ", ": ", "; ")
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"]
129_PAUSE_WEIGHT = 0.35
130_CHAR_WEIGHT = 0.06
131_BASE_DURATION = 5.0
132_MUSIC_RESUME_DELAY = 2.0
134_LOGGER = logging.getLogger(__name__)
136OPTION_MEDIA_AUTO_PAUSE = "media_auto_pause"
139def _estimate_tts_duration(message: str, char_weight: float = _CHAR_WEIGHT) -> float:
140 """Estimate pronunciation duration in seconds, stripping SSML first.
142 Formula from energywave/multinotify:
143 duration = BASE + pause_chars x PAUSE_WEIGHT + chars x char_weight
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).
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
157class AlexaMediaPlayerTransport(Transport):
158 """Notify via Amazon Alexa announcements with full volume management.
160 options:
161 message_usage: standard | use_title | combine_title
162 media_auto_pause: bool, sets the default for pausing music/restoring volume
163 """
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 ]
174 def __init__(self, *args: Any, **kwargs: Any) -> None:
175 super().__init__(*args, **kwargs)
177 @property
178 def supported_features(self) -> TransportFeature:
179 return TransportFeature.MESSAGE | TransportFeature.SPOKEN
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
197 @property
198 def target_categories(self) -> list[str | TargetEntityCategory]:
199 return [TargetEntityCategory(domain="media_player", platform=HA_ALEXA_MEDIA_PLAYER_PLATFORM)]
201 def validate_action(self, action: str | None) -> bool:
202 return action is not None
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
211 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
212 if self.delivery_defaults.action:
213 return {self.name: {}}
214 return {}
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
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.
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
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."""
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
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}
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 )
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>'
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)
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
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 {}
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)
357 message: str | None = envelope.message
358 if audio_url:
359 message = self._audio_ssml(audio_url, message)
361 auto_pause = envelope.delivery.option_bool(OPTION_MEDIA_AUTO_PAUSE, True)
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)
383 states: dict[str, dict[str, Any]] = {}
384 needs_restore = False
385 volume_set_failed: set[str] = set()
387 if auto_pause:
388 # Pre-announce
389 needs_restore = requested_volume is not None or pause_music
391 if needs_restore:
392 states = await self._snapshot_states(media_players, volume_fallback)
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 )
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)
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
427 result = False
428 try:
429 result = await self.call_action(envelope, action_data=action_data)
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 )
454 return result