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
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 14:29 +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 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
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
57"""
59from __future__ import annotations
61import asyncio
62import logging
63import re
64from typing import TYPE_CHECKING, Any, ClassVar, cast
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
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
96if TYPE_CHECKING:
97 from homeassistant.core import Context as HAContext
99 from custom_components.supernotify.envelope import Envelope
100 from custom_components.supernotify.hass_api import HomeAssistantAPI
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"
108RE_SSML_TAG = re.compile(r"<[^>]+>")
109PAUSE_CHARS = (", ", ". ", "! ", "? ", ": ", "; ")
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"]
115_PAUSE_WEIGHT = 0.35
116_CHAR_WEIGHT = 0.06
117_BASE_DURATION = 5.0
118_MUSIC_RESUME_DELAY = 2.0
120_LOGGER = logging.getLogger(__name__)
122OPTION_MEDIA_AUTO_PAUSE = "media_auto_pause"
125def _estimate_tts_duration(message: str, char_weight: float = _CHAR_WEIGHT) -> float:
126 """Estimate pronunciation duration in seconds, stripping SSML first.
128 Formula from energywave/multinotify:
129 duration = BASE + pause_chars x PAUSE_WEIGHT + chars x char_weight
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).
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
143class AlexaMediaPlayerTransport(Transport):
144 """Notify via Amazon Alexa announcements with full volume management.
146 options:
147 message_usage: standard | use_title | combine_title
148 media_auto_pause: bool, sets the default for pausing music/restoring volume
149 """
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 ]
160 def __init__(self, *args: Any, **kwargs: Any) -> None:
161 super().__init__(*args, **kwargs)
163 @property
164 def supported_features(self) -> TransportFeature:
165 return TransportFeature.MESSAGE | TransportFeature.SPOKEN
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
183 @property
184 def target_categories(self) -> list[str | TargetEntityCategory]:
185 return [TargetEntityCategory(domain="media_player", platform=HA_ALEXA_MEDIA_PLAYER_PLATFORM)]
187 def validate_action(self, action: str | None) -> bool:
188 return action is not None
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
197 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
198 if self.delivery_defaults.action:
199 return {self.name: {}}
200 return {}
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
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.
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
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."""
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
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}
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 )
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)
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
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 {}
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))
330 auto_pause = envelope.delivery.option_bool(OPTION_MEDIA_AUTO_PAUSE, True)
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)
352 states: dict[str, dict[str, Any]] = {}
353 needs_restore = False
354 volume_set_failed: set[str] = set()
356 if auto_pause:
357 # Pre-announce
358 needs_restore = requested_volume is not None or pause_music
360 if needs_restore:
361 states = await self._snapshot_states(media_players, volume_fallback)
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 )
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)
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
391 result = False
392 try:
393 result = await self.call_action(envelope, action_data=action_data)
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 )
417 return result