Coverage for custom_components/supernotify/transports/tts.py: 99%
102 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
1from __future__ import annotations
3import logging
4from typing import TYPE_CHECKING, Any, ClassVar
6from homeassistant.components.notify.const import ATTR_DATA, ATTR_MESSAGE
7from homeassistant.components.tts.const import ATTR_CACHE, ATTR_LANGUAGE, ATTR_OPTIONS
8from homeassistant.const import ATTR_ENTITY_ID
9from homeassistant.helpers.typing import ConfigType
11from custom_components.supernotify.const import (
12 ATTR_MOBILE_APP_ID,
13 MANUFACTURER_APPLE,
14 RE_MEDIA_PLAYER_ENTITY_ID,
15 TRANSPORT_TTS,
16)
17from custom_components.supernotify.model import (
18 DebugTrace,
19 MessageOnlyPolicy,
20 SelectionRule,
21 SuppressionReason,
22 TransportConfig,
23 TransportFeature,
24)
25from custom_components.supernotify.options import (
26 OPTION_DEVICE_DISCOVERY,
27 OPTION_DEVICE_DOMAIN,
28 OPTION_DEVICE_MANUFACTURER_SELECT,
29 OPTION_MESSAGE_USAGE,
30 OPTION_SIMPLIFY_TEXT,
31 OPTION_STRIP_URLS,
32 OPTION_TARGET_SELECT,
33 SELECT_EXCLUDE,
34 DeliveryOption,
35)
36from custom_components.supernotify.target import Target, TargetEntityCategory
37from custom_components.supernotify.transport import Transport
39if TYPE_CHECKING:
40 from custom_components.supernotify.envelope import Envelope
41 from custom_components.supernotify.hass_api import HomeAssistantAPI, TrackedDeviceDetails
43_LOGGER = logging.getLogger(__name__)
44RE_MOBILE_APP = r"(notify\.)?mobile_app_[a-z0-9_]+"
45ATTR_MEDIA_PLAYER_ENTITY_ID = "media_player_entity_id" # mypy flags up import from tts
46OPTION_TTS_ENTITY_ID = "tts_entity_id"
47# used when no `tts_entity_id` is configured, if present, else the first tts entity found
48DEFAULT_TTS_ENTITY_ID = "tts.home_assistant_cloud"
51class TTSTransport(Transport):
52 """Notify via Home Assistant's built-in tts.speak action
54 options:
55 message_usage: standard | use_title | combine_title | message_only
57 """
59 name = TRANSPORT_TTS
60 integration_domain = "tts"
61 declared_options: ClassVar[list[DeliveryOption]] = [
62 DeliveryOption(OPTION_TTS_ENTITY_ID, "The tts entity used to generate speech"),
63 ]
65 def __init__(self, *args: Any, **kwargs: Any) -> None:
66 super().__init__(*args, **kwargs)
68 @property
69 def supported_features(self) -> TransportFeature:
70 return TransportFeature.MESSAGE | TransportFeature.SPOKEN
72 def validate_action(self, action: str | None) -> bool:
73 """Allow default action to be overridden, such as tts.say or tts.cloud_speak"""
74 return action is not None
76 def is_viable(self, hass_api: HomeAssistantAPI) -> bool:
77 if not hass_api.has_service("tts", "speak"):
78 _LOGGER.debug("SUPERNOTIFY No tts.speak action available, `tts` transport not configured")
79 return False
80 if not hass_api.entity_ids_for_domain("media_player"):
81 _LOGGER.debug("SUPERNOTIFY No media players available, `tts` transport not configured")
82 return False
83 return True
85 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
86 return {self.name: {}}
88 @property
89 def default_config(self) -> TransportConfig:
90 config = super().default_config
91 config.delivery_defaults.action = "tts.speak"
92 # v1.8.1 added a FIRST selection rank here, remove for v2.11 since it seemed obsolete
93 config.delivery_defaults.options = {
94 OPTION_SIMPLIFY_TEXT: True,
95 OPTION_STRIP_URLS: True,
96 OPTION_MESSAGE_USAGE: MessageOnlyPolicy.STANDARD,
97 OPTION_TARGET_SELECT: [RE_MEDIA_PLAYER_ENTITY_ID, RE_MOBILE_APP],
98 OPTION_DEVICE_DISCOVERY: False,
99 OPTION_DEVICE_DOMAIN: ["mobile_app"],
100 OPTION_DEVICE_MANUFACTURER_SELECT: {SELECT_EXCLUDE: [MANUFACTURER_APPLE]},
101 }
102 return config
104 @property
105 def other_target_categories(self) -> list[str | TargetEntityCategory]:
106 # media_player is ambiguous the same way as kodi/media/alexa_media_player's; mobile_app_id
107 # is already mobile_push's unique category, but tts using it too (to speak via the app's
108 # own TTS bridge, not to push-notify) doesn't drive auto-selection from `other`
109 return [TargetEntityCategory(domain="media_player"), ATTR_MOBILE_APP_ID]
111 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
112 _LOGGER.debug("SUPERNOTIFY tts: %s", envelope.message)
114 delivered: bool = False
116 media_player_targets = envelope.target.entity_ids or []
117 if media_player_targets:
118 delivered = await self.call_media_players(envelope, media_player_targets)
120 mobile_targets = envelope.target.mobile_app_ids or []
121 if mobile_targets and await self.call_mobile_apps(envelope, mobile_targets):
122 delivered = True
123 return delivered
125 async def call_media_players(self, envelope: Envelope, targets: list[str]) -> bool:
126 action_data: dict[str, Any] = {ATTR_MESSAGE: envelope.message or ""}
127 if ATTR_LANGUAGE in envelope.data:
128 action_data[ATTR_LANGUAGE] = envelope.data[ATTR_LANGUAGE]
129 if ATTR_CACHE in envelope.data:
130 action_data[ATTR_CACHE] = envelope.data[ATTR_CACHE]
131 if ATTR_OPTIONS in envelope.data:
132 action_data[ATTR_OPTIONS] = envelope.data[ATTR_OPTIONS]
133 if (envelope.delivery.action or "").partition(".")[2] != "speak":
134 # legacy `<platform>_say` actions, like `tts.cloud_say`, target the media players directly
135 return await self.call_action(envelope, action_data=action_data, target_data={ATTR_ENTITY_ID: targets})
137 tts_entity_id: str | None = self.tts_entity_id(envelope)
138 if tts_entity_id is None:
139 _LOGGER.warning("SUPERNOTIFY tts: no tts entity found, set the `%s` option", OPTION_TTS_ENTITY_ID)
140 envelope.skipped = 1
141 envelope.skip_reason = SuppressionReason.NO_TARGET
142 return False
143 target_data: dict[str, Any] = {ATTR_ENTITY_ID: tts_entity_id}
145 if targets and len(targets) == 1:
146 action_data[ATTR_MEDIA_PLAYER_ENTITY_ID] = targets[0]
147 else:
148 # despite the docs, the tts code accepts a list of media_player entity ids
149 action_data[ATTR_MEDIA_PLAYER_ENTITY_ID] = targets
151 return await self.call_action(envelope, action_data=action_data, target_data=target_data)
153 def tts_entity_id(self, envelope: Envelope) -> str | None:
154 """The configured tts entity, or else Nabu Casa's if there is one, or else the first found"""
155 configured: str | None = envelope.delivery.options.get(OPTION_TTS_ENTITY_ID)
156 if configured:
157 return configured
158 available: list[str] = self.hass_api.entity_ids_for_domain("tts")
159 if DEFAULT_TTS_ENTITY_ID in available:
160 return DEFAULT_TTS_ENTITY_ID
161 return available[0] if available else None
163 async def call_mobile_apps(self, envelope: Envelope, targets: list[str]) -> bool:
164 # "TTS" is the Companion App's trigger for speaking `tts_text`, not text to translate
165 action_data: dict[str, Any] = {ATTR_MESSAGE: "TTS", ATTR_DATA: {"tts_text": envelope.message or ""}}
166 if "media_stream" in envelope.data:
167 action_data[ATTR_DATA]["media_stream"] = envelope.data["media_stream"]
169 manufacturer_filter = SelectionRule(envelope.delivery.options.get(OPTION_DEVICE_MANUFACTURER_SELECT))
170 at_least_one: bool = False
171 for target in targets:
172 mobile_info: TrackedDeviceDetails | None = self.context.hass_api.mobile_app_by_id(target)
173 if not mobile_info:
174 _LOGGER.debug("SUPERNOTIFY Skipping unknown tts mobile app target: %s", target)
175 elif not manufacturer_filter.match(mobile_info.manufacturer):
176 _LOGGER.debug("SUPERNOTIFY Skipping tts target excluded by manufacturer filter: %s", mobile_info)
177 else:
178 full_target = target if Target.is_notify_entity(target) else f"notify.{target}"
179 if await self.call_action(envelope, qualified_action=full_target, action_data=action_data, implied_target=True):
180 at_least_one = True
181 return at_least_one