Coverage for custom_components/supernotify/transports/tts.py: 35%
68 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-01-07 15:35 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-01-07 15:35 +0000
1import logging
2from typing import TYPE_CHECKING, Any
4from homeassistant.components.notify.const import ATTR_DATA, ATTR_MESSAGE
5from homeassistant.components.tts.const import ATTR_CACHE, ATTR_LANGUAGE, ATTR_OPTIONS
6from homeassistant.const import ATTR_ENTITY_ID
8from custom_components.supernotify import (
9 ATTR_MOBILE_APP_ID,
10 OPTION_DEVICE_DISCOVERY,
11 OPTION_DEVICE_DOMAIN,
12 OPTION_DEVICE_MANUFACTURER_SELECT,
13 OPTION_MESSAGE_USAGE,
14 OPTION_SIMPLIFY_TEXT,
15 OPTION_STRIP_URLS,
16 OPTION_TARGET_CATEGORIES,
17 OPTION_TARGET_SELECT,
18 OPTION_TTS_ENTITY_ID,
19 SELECT_EXCLUDE,
20 TRANSPORT_TTS,
21 SelectionRank,
22)
23from custom_components.supernotify.envelope import Envelope
24from custom_components.supernotify.model import (
25 DebugTrace,
26 MessageOnlyPolicy,
27 Target,
28 TargetRequired,
29 TransportConfig,
30 TransportFeature,
31)
32from custom_components.supernotify.transport import Transport
34if TYPE_CHECKING:
35 from custom_components.supernotify.hass_api import DeviceInfo
37_LOGGER = logging.getLogger(__name__)
38RE_VALID_MEDIA_PLAYER = r"media_player\.[A-Za-z0-9_]+"
39RE_MOBILE_APP = r"(notify\.)?mobile_app_[a-z0-9_]+"
40ATTR_MEDIA_PLAYER_ENTITY_ID = "media_player_entity_id" # mypy flags up import from tts
43class TTSTransport(Transport):
44 """Notify via Home Assistant's built-in tts.speak action
46 options:
47 message_usage: standard | use_title | combine_title
49 """
51 name = TRANSPORT_TTS
53 def __init__(self, *args: Any, **kwargs: Any) -> None:
54 super().__init__(*args, **kwargs)
56 @property
57 def supported_features(self) -> TransportFeature:
58 return TransportFeature.MESSAGE
60 def validate_action(self, action: str | None) -> bool:
61 """Allow default action to be overridden, such as tts.say or tts.cloud_speak"""
62 return action is not None
64 @property
65 def default_config(self) -> TransportConfig:
66 config = TransportConfig()
67 config.delivery_defaults.action = "tts.speak"
68 config.delivery_defaults.target_required = TargetRequired.ALWAYS
69 config.delivery_defaults.selection_rank = SelectionRank.FIRST
70 config.delivery_defaults.options = {
71 OPTION_SIMPLIFY_TEXT: True,
72 OPTION_STRIP_URLS: True,
73 OPTION_MESSAGE_USAGE: MessageOnlyPolicy.STANDARD,
74 OPTION_TARGET_CATEGORIES: [ATTR_ENTITY_ID, ATTR_MOBILE_APP_ID],
75 OPTION_TARGET_SELECT: [RE_VALID_MEDIA_PLAYER, RE_MOBILE_APP],
76 OPTION_TTS_ENTITY_ID: "tts.home_assistant_cloud",
77 OPTION_DEVICE_DISCOVERY: False,
78 OPTION_DEVICE_DOMAIN: ["mobile_app"],
79 OPTION_DEVICE_MANUFACTURER_SELECT: {SELECT_EXCLUDE: ["Apple"]},
80 }
81 return config
83 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: # noqa: ARG002
84 _LOGGER.debug("SUPERNOTIFY tts: %s", envelope.message)
86 delivered: bool = False
88 media_player_targets = envelope.target.entity_ids or []
89 if media_player_targets:
90 delivered = await self.call_media_players(envelope, media_player_targets)
92 mobile_targets = envelope.target.mobile_app_ids or []
93 if mobile_targets:
94 if await self.call_mobile_apps(envelope, mobile_targets):
95 delivered = True
96 return delivered
98 async def call_media_players(self, envelope: Envelope, targets: list[str]) -> bool:
99 action_data: dict[str, Any] = {ATTR_MESSAGE: envelope.message or ""}
100 if ATTR_LANGUAGE in envelope.data:
101 action_data[ATTR_LANGUAGE] = envelope.data[ATTR_LANGUAGE]
102 if ATTR_CACHE in envelope.data:
103 action_data[ATTR_CACHE] = envelope.data[ATTR_CACHE]
104 if ATTR_OPTIONS in envelope.data:
105 action_data[ATTR_OPTIONS] = envelope.data[ATTR_OPTIONS]
106 target_data: dict[str, Any] = {ATTR_ENTITY_ID: envelope.delivery.options.get(OPTION_TTS_ENTITY_ID)}
108 if targets and len(targets) == 1:
109 action_data[ATTR_MEDIA_PLAYER_ENTITY_ID] = targets[0]
110 else:
111 # despite the docs, the tts code accepts a list of media_player entity ids
112 action_data[ATTR_MEDIA_PLAYER_ENTITY_ID] = targets
114 return await self.call_action(envelope, action_data=action_data, target_data=target_data)
116 async def call_mobile_apps(self, envelope: Envelope, targets: list[str]) -> bool:
117 action_data: dict[str, Any] = {ATTR_MESSAGE: "TTS", ATTR_DATA: {"tts_text": envelope.message or ""}}
118 if "media_stream" in envelope.data:
119 action_data["media_stream"] = envelope.data["media_stream"]
121 at_least_one: bool = False
122 for target in targets:
123 bare_target = target.replace("notify.", "", 1) if target.startswith("notify.") else target
124 mobile_info: DeviceInfo | None = self.context.hass_api.mobile_app_by_id(bare_target)
125 if not mobile_info or mobile_info.manufacturer == "Apple":
126 _LOGGER.debug("SUPERNOTIFY Skipping tts target that isn't confirmed as android: %s", mobile_info)
127 else:
128 full_target = target if Target.is_notify_entity(target) else f"notify.{target}"
129 if await self.call_action(envelope, qualified_action=full_target, action_data=action_data, implied_target=True):
130 at_least_one = True
131 return at_least_one