diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index b134d67403a..1ee7392eccf 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -33,7 +33,7 @@ PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" PREF_GOOGLE_CONNECTED = "google_connected" PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable" -DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") +DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural") DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = True DEFAULT_GOOGLE_REPORT_STATE = True diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index cb13cd75944..1a8fd7dbea9 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -16,7 +16,7 @@ from aiohttp import web import attr from hass_nabucasa import Cloud, auth, thingtalk from hass_nabucasa.const import STATE_DISCONNECTED -from hass_nabucasa.voice import MAP_VOICE +from hass_nabucasa.voice import TTS_VOICES import voluptuous as vol from homeassistant.components import websocket_api @@ -426,6 +426,16 @@ async def websocket_subscription( async_manage_legacy_subscription_issue(hass, data) +def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]: + """Validate language and voice.""" + language, voice = value + if language not in TTS_VOICES: + raise vol.Invalid(f"Invalid language {language}") + if voice not in TTS_VOICES[language]: + raise vol.Invalid(f"Invalid voice {voice} for language {language}") + return value + + @_require_cloud_login @websocket_api.websocket_command( { @@ -436,7 +446,7 @@ async def websocket_subscription( vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( - vol.Coerce(tuple), vol.In(MAP_VOICE) + vol.Coerce(tuple), validate_language_voice ), vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, } @@ -840,5 +850,12 @@ def tts_info( ) -> None: """Fetch available tts info.""" connection.send_result( - msg["id"], {"languages": [(lang, gender.value) for lang, gender in MAP_VOICE]} + msg["id"], + { + "languages": [ + (language, voice) + for language, voices in TTS_VOICES.items() + for voice in voices + ] + }, ) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 0a0989ed4aa..af4e68194d6 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -6,6 +6,8 @@ from collections.abc import Callable, Coroutine from typing import Any import uuid +from hass_nabucasa.voice import MAP_VOICE + from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook @@ -48,7 +50,7 @@ from .const import ( STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 3 +STORAGE_VERSION_MINOR = 4 ALEXA_SETTINGS_VERSION = 3 GOOGLE_SETTINGS_VERSION = 3 @@ -82,6 +84,24 @@ class CloudPreferencesStore(Store): # In HA Core 2024.9, remove the import and also remove the Google # assistant store if it's not been migrated by manual Google assistant old_data.setdefault(PREF_GOOGLE_CONNECTED, await google_connected()) + if old_minor_version < 4: + # Update the default TTS voice to the new default. + # The default tts voice is a tuple. + # The first item is the language, the second item used to be gender. + # The new second item is the voice name. + default_tts_voice = old_data.get(PREF_TTS_DEFAULT_VOICE) + if default_tts_voice and (voice_item_two := default_tts_voice[1]) in ( + "female", + "male", + ): + language: str = default_tts_voice[0] + if voice := MAP_VOICE.get((language, voice_item_two)): + old_data[PREF_TTS_DEFAULT_VOICE] = ( + language, + voice, + ) + else: + old_data[PREF_TTS_DEFAULT_VOICE] = DEFAULT_TTS_DEFAULT_VOICE return old_data @@ -332,7 +352,10 @@ class CloudPreferences: @property def tts_default_voice(self) -> tuple[str, str]: - """Return the default TTS voice.""" + """Return the default TTS voice. + + The return value is a tuple of language and voice. + """ return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return] async def get_cloud_user(self) -> str: diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 4bef2ac9ba3..30ef88cafda 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -24,6 +24,17 @@ } }, "issues": { + "deprecated_gender": { + "title": "The '{deprecated_option}' text-to-speech option is deprecated", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::cloud::issues::deprecated_voice::title%]", + "description": "The '{deprecated_option}' option for text-to-speech in the {integration_name} integration is deprecated and will be removed.\nPlease update your automations and scripts to replace the '{deprecated_option}' option with an option for a supported '{replacement_option}' instead." + } + } + } + }, "deprecated_tts_platform_config": { "title": "The Cloud text-to-speech platform configuration is deprecated", "description": "The whole `platform: cloud` entry under the `tts:` section in configuration.yaml is deprecated and should be removed. You can use the UI to change settings for the Cloud text-to-speech platform. Please adjust your configuration.yaml and restart Home Assistant to fix this issue." diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index baaec15ac57..7922fc80201 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -7,7 +7,7 @@ import logging from typing import Any from hass_nabucasa import Cloud -from hass_nabucasa.voice import MAP_VOICE, TTS_VOICES, AudioOutput, VoiceError +from hass_nabucasa.voice import MAP_VOICE, TTS_VOICES, AudioOutput, Gender, VoiceError import voluptuous as vol from homeassistant.components.tts import ( @@ -97,17 +97,7 @@ async def async_get_engine( ) -> CloudProvider: """Set up Cloud speech component.""" cloud: Cloud[CloudClient] = hass.data[DOMAIN] - - language: str | None - gender: str | None - if discovery_info is not None: - language = None - gender = None - else: - language = config[CONF_LANG] - gender = config[ATTR_GENDER] - - cloud_provider = CloudProvider(cloud, language, gender) + cloud_provider = CloudProvider(cloud) if discovery_info is not None: discovery_info["platform_loaded"].set() return cloud_provider @@ -134,11 +124,11 @@ class CloudTTSEntity(TextToSpeechEntity): def __init__(self, cloud: Cloud[CloudClient]) -> None: """Initialize cloud text-to-speech entity.""" self.cloud = cloud - self._language, self._gender = cloud.client.prefs.tts_default_voice + self._language, self._voice = cloud.client.prefs.tts_default_voice async def _sync_prefs(self, prefs: CloudPreferences) -> None: """Sync preferences.""" - self._language, self._gender = prefs.tts_default_voice + self._language, self._voice = prefs.tts_default_voice @property def default_language(self) -> str: @@ -149,8 +139,8 @@ class CloudTTSEntity(TextToSpeechEntity): def default_options(self) -> dict[str, Any]: """Return a dict include default options.""" return { - ATTR_GENDER: self._gender, ATTR_AUDIO_OUTPUT: AudioOutput.MP3, + ATTR_VOICE: self._voice, } @property @@ -161,6 +151,7 @@ class CloudTTSEntity(TextToSpeechEntity): @property def supported_options(self) -> list[str]: """Return list of supported options like voice, emotion.""" + # The gender option is deprecated and will be removed in 2024.10.0. return [ATTR_GENDER, ATTR_VOICE, ATTR_AUDIO_OUTPUT] async def async_added_to_hass(self) -> None: @@ -184,6 +175,8 @@ class CloudTTSEntity(TextToSpeechEntity): self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS from Home Assistant Cloud.""" + gender: Gender | str | None = options.get(ATTR_GENDER) + gender = handle_deprecated_gender(self.hass, gender) original_voice: str | None = options.get(ATTR_VOICE) voice = handle_deprecated_voice(self.hass, original_voice) # Process TTS @@ -191,7 +184,7 @@ class CloudTTSEntity(TextToSpeechEntity): data = await self.cloud.voice.process_tts( text=message, language=language, - gender=options.get(ATTR_GENDER), + gender=gender, voice=voice, output=options[ATTR_AUDIO_OUTPUT], ) @@ -205,24 +198,16 @@ class CloudTTSEntity(TextToSpeechEntity): class CloudProvider(Provider): """Home Assistant Cloud speech API provider.""" - def __init__( - self, cloud: Cloud[CloudClient], language: str | None, gender: str | None - ) -> None: + def __init__(self, cloud: Cloud[CloudClient]) -> None: """Initialize cloud provider.""" self.cloud = cloud self.name = "Cloud" - self._language = language - self._gender = gender - - if self._language is not None: - return - - self._language, self._gender = cloud.client.prefs.tts_default_voice + self._language, self._voice = cloud.client.prefs.tts_default_voice cloud.client.prefs.async_listen_updates(self._sync_prefs) async def _sync_prefs(self, prefs: CloudPreferences) -> None: """Sync preferences.""" - self._language, self._gender = prefs.tts_default_voice + self._language, self._voice = prefs.tts_default_voice @property def default_language(self) -> str | None: @@ -237,6 +222,7 @@ class CloudProvider(Provider): @property def supported_options(self) -> list[str]: """Return list of supported options like voice, emotion.""" + # The gender option is deprecated and will be removed in 2024.10.0. return [ATTR_GENDER, ATTR_VOICE, ATTR_AUDIO_OUTPUT] @callback @@ -250,23 +236,25 @@ class CloudProvider(Provider): def default_options(self) -> dict[str, Any]: """Return a dict include default options.""" return { - ATTR_GENDER: self._gender, ATTR_AUDIO_OUTPUT: AudioOutput.MP3, + ATTR_VOICE: self._voice, } async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS from Home Assistant Cloud.""" - original_voice: str | None = options.get(ATTR_VOICE) assert self.hass is not None + gender: Gender | str | None = options.get(ATTR_GENDER) + gender = handle_deprecated_gender(self.hass, gender) + original_voice: str | None = options.get(ATTR_VOICE) voice = handle_deprecated_voice(self.hass, original_voice) # Process TTS try: data = await self.cloud.voice.process_tts( text=message, language=language, - gender=options.get(ATTR_GENDER), + gender=gender, voice=voice, output=options[ATTR_AUDIO_OUTPUT], ) @@ -277,6 +265,32 @@ class CloudProvider(Provider): return (str(options[ATTR_AUDIO_OUTPUT].value), data) +@callback +def handle_deprecated_gender( + hass: HomeAssistant, + gender: Gender | str | None, +) -> Gender | None: + """Handle deprecated gender.""" + if gender is None: + return None + async_create_issue( + hass, + DOMAIN, + "deprecated_gender", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + breaks_in_ha_version="2024.10.0", + translation_key="deprecated_gender", + translation_placeholders={ + "integration_name": "Home Assistant Cloud", + "deprecated_option": "gender", + "replacement_option": "voice", + }, + ) + return Gender(gender) + + @callback def handle_deprecated_voice( hass: HomeAssistant, diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 269b7b5d0c5..40092997c79 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -2,13 +2,15 @@ from copy import deepcopy from http import HTTPStatus +import json from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import aiohttp -from hass_nabucasa import thingtalk, voice +from hass_nabucasa import thingtalk from hass_nabucasa.auth import Unauthenticated, UnknownError from hass_nabucasa.const import STATE_CONNECTED +from hass_nabucasa.voice import TTS_VOICES import pytest from homeassistant.components.alexa import errors as alexa_errors @@ -17,6 +19,7 @@ from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY from homeassistant.components.cloud.const import DEFAULT_EXPOSED_DOMAINS, DOMAIN from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.components.homeassistant import exposed_entities +from homeassistant.components.websocket_api import ERR_INVALID_FORMAT from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -774,7 +777,7 @@ async def test_websocket_status( "google_report_state": True, "remote_allow_remote_enable": True, "remote_enabled": False, - "tts_default_voice": ["en-US", "female"], + "tts_default_voice": ["en-US", "JennyNeural"], }, "alexa_entities": { "include_domains": [], @@ -896,14 +899,13 @@ async def test_websocket_update_preferences( client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 5, "type": "cloud/update_prefs", "alexa_enabled": False, "google_enabled": False, "google_secure_devices_pin": "1234", - "tts_default_voice": ["en-GB", "male"], + "tts_default_voice": ["en-GB", "RyanNeural"], "remote_allow_remote_enable": False, } ) @@ -914,7 +916,34 @@ async def test_websocket_update_preferences( assert not cloud.client.prefs.alexa_enabled assert cloud.client.prefs.google_secure_devices_pin == "1234" assert cloud.client.prefs.remote_allow_remote_enable is False - assert cloud.client.prefs.tts_default_voice == ("en-GB", "male") + assert cloud.client.prefs.tts_default_voice == ("en-GB", "RyanNeural") + + +@pytest.mark.parametrize( + ("language", "voice"), [("en-GB", "bad_voice"), ("bad_language", "RyanNeural")] +) +async def test_websocket_update_preferences_bad_voice( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, + language: str, + voice: str, +) -> None: + """Test updating preference.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "cloud/update_prefs", + "tts_default_voice": [language, voice], + } + ) + response = await client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == ERR_INVALID_FORMAT + assert cloud.client.prefs.tts_default_voice == ("en-US", "JennyNeural") async def test_websocket_update_preferences_alexa_report_state( @@ -1596,24 +1625,23 @@ async def test_tts_info( setup_cloud: None, ) -> None: """Test that we can get TTS info.""" - # Verify the format is as expected - assert voice.MAP_VOICE[("en-US", voice.Gender.FEMALE)] == "JennyNeural" - client = await hass_ws_client(hass) - with patch.dict( - "homeassistant.components.cloud.http_api.MAP_VOICE", - { - ("en-US", voice.Gender.MALE): "GuyNeural", - ("en-US", voice.Gender.FEMALE): "JennyNeural", - }, - clear=True, - ): - await client.send_json({"id": 5, "type": "cloud/tts/info"}) - response = await client.receive_json() + await client.send_json_auto_id({"type": "cloud/tts/info"}) + response = await client.receive_json() assert response["success"] - assert response["result"] == {"languages": [["en-US", "male"], ["en-US", "female"]]} + assert response["result"] == { + "languages": json.loads( + json.dumps( + [ + (language, voice) + for language, voices in TTS_VOICES.items() + for voice in voices + ] + ) + ) + } @pytest.mark.parametrize( diff --git a/tests/components/cloud/test_prefs.py b/tests/components/cloud/test_prefs.py index 86e86a71583..9b0fa4c01d7 100644 --- a/tests/components/cloud/test_prefs.py +++ b/tests/components/cloud/test_prefs.py @@ -1,13 +1,15 @@ """Test Cloud preferences.""" from typing import Any -from unittest.mock import ANY, patch +from unittest.mock import ANY, MagicMock, patch import pytest from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.components.cloud.const import DOMAIN, PREF_TTS_DEFAULT_VOICE from homeassistant.components.cloud.prefs import STORAGE_KEY, CloudPreferences from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component async def test_set_username(hass: HomeAssistant) -> None: @@ -149,3 +151,26 @@ async def test_import_google_assistant_settings( prefs = CloudPreferences(hass) await prefs.async_initialize() assert prefs.google_connected == google_connected + + +@pytest.mark.parametrize( + ("stored_language", "expected_language", "voice"), + [("en-US", "en-US", "GuyNeural"), ("missing_language", "en-US", "JennyNeural")], +) +async def test_tts_default_voice_legacy_gender( + hass: HomeAssistant, + cloud: MagicMock, + hass_storage: dict[str, Any], + stored_language: str, + expected_language: str, + voice: str, +) -> None: + """Test tts with legacy gender as default tts voice setting in storage.""" + hass_storage[STORAGE_KEY] = { + "version": 1, + "data": {PREF_TTS_DEFAULT_VOICE: [stored_language, "male"]}, + } + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + assert cloud.client.prefs.tts_default_voice == (expected_language, voice) diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index 3797a9784e1..f549f62b889 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -6,7 +6,7 @@ from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from hass_nabucasa.voice import MAP_VOICE, VoiceError, VoiceTokenError +from hass_nabucasa.voice import TTS_VOICES, VoiceError, VoiceTokenError import pytest import voluptuous as vol @@ -44,7 +44,11 @@ async def internal_url_mock(hass: HomeAssistant) -> None: def test_default_exists() -> None: """Test our default language exists.""" - assert const.DEFAULT_TTS_DEFAULT_VOICE in MAP_VOICE + assert const.DEFAULT_TTS_DEFAULT_VOICE[0] in TTS_VOICES + assert ( + const.DEFAULT_TTS_DEFAULT_VOICE[1] + in TTS_VOICES[const.DEFAULT_TTS_DEFAULT_VOICE[0]] + ) def test_schema() -> None: @@ -105,7 +109,7 @@ async def test_prefs_default_voice( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - assert cloud.client.prefs.tts_default_voice == ("en-US", "female") + assert cloud.client.prefs.tts_default_voice == ("en-US", "JennyNeural") on_start_callback = cloud.register_on_start.call_args[0][0] await on_start_callback() @@ -116,13 +120,13 @@ async def test_prefs_default_voice( assert engine is not None # The platform config provider will be overridden by the discovery info provider. assert engine.default_language == "en-US" - assert engine.default_options == {"gender": "female", "audio_output": "mp3"} + assert engine.default_options == {"audio_output": "mp3", "voice": "JennyNeural"} - await set_cloud_prefs({"tts_default_voice": ("nl-NL", "male")}) + await set_cloud_prefs({"tts_default_voice": ("nl-NL", "MaartenNeural")}) await hass.async_block_till_done() assert engine.default_language == "nl-NL" - assert engine.default_options == {"gender": "male", "audio_output": "mp3"} + assert engine.default_options == {"audio_output": "mp3", "voice": "MaartenNeural"} async def test_deprecated_platform_config( @@ -224,11 +228,11 @@ async def test_get_tts_audio( "url": ( "http://example.local:8123/api/tts_proxy/" "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + f"_en-us_5c97d21c48_{expected_url_suffix}.mp3" ), "path": ( "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + f"_en-us_5c97d21c48_{expected_url_suffix}.mp3" ), } await hass.async_block_till_done() @@ -237,7 +241,7 @@ async def test_get_tts_audio( assert mock_process_tts.call_args is not None assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." assert mock_process_tts.call_args.kwargs["language"] == "en-US" - assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["gender"] is None assert mock_process_tts.call_args.kwargs["output"] == "mp3" @@ -276,11 +280,11 @@ async def test_get_tts_audio_logged_out( "url": ( "http://example.local:8123/api/tts_proxy/" "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + f"_en-us_5c97d21c48_{expected_url_suffix}.mp3" ), "path": ( "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + f"_en-us_5c97d21c48_{expected_url_suffix}.mp3" ), } await hass.async_block_till_done() @@ -289,7 +293,7 @@ async def test_get_tts_audio_logged_out( assert mock_process_tts.call_args is not None assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." assert mock_process_tts.call_args.kwargs["language"] == "en-US" - assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["gender"] is None assert mock_process_tts.call_args.kwargs["output"] == "mp3" @@ -340,11 +344,11 @@ async def test_tts_entity( "url": ( "http://example.local:8123/api/tts_proxy/" "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_e09b5a0968_{entity_id}.mp3" + f"_en-us_5c97d21c48_{entity_id}.mp3" ), "path": ( "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_en-us_e09b5a0968_{entity_id}.mp3" + f"_en-us_5c97d21c48_{entity_id}.mp3" ), } await hass.async_block_till_done() @@ -353,7 +357,7 @@ async def test_tts_entity( assert mock_process_tts.call_args is not None assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." assert mock_process_tts.call_args.kwargs["language"] == "en-US" - assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["gender"] is None assert mock_process_tts.call_args.kwargs["output"] == "mp3" state = hass.states.get(entity_id) @@ -480,11 +484,11 @@ async def test_deprecated_voice( "url": ( "http://example.local:8123/api/tts_proxy/" "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_{language.lower()}_1c4ec2f170_{expected_url_suffix}.mp3" + f"_{language.lower()}_87567e3e29_{expected_url_suffix}.mp3" ), "path": ( "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_{language.lower()}_1c4ec2f170_{expected_url_suffix}.mp3" + f"_{language.lower()}_87567e3e29_{expected_url_suffix}.mp3" ), } await hass.async_block_till_done() @@ -493,7 +497,7 @@ async def test_deprecated_voice( assert mock_process_tts.call_args is not None assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["gender"] is None assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice assert mock_process_tts.call_args.kwargs["output"] == "mp3" issue = issue_registry.async_get_issue( @@ -513,11 +517,11 @@ async def test_deprecated_voice( "url": ( "http://example.local:8123/api/tts_proxy/" "42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_{language.lower()}_a1c3b0ac0e_{expected_url_suffix}.mp3" + f"_{language.lower()}_13646b7d32_{expected_url_suffix}.mp3" ), "path": ( "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - f"_{language.lower()}_a1c3b0ac0e_{expected_url_suffix}.mp3" + f"_{language.lower()}_13646b7d32_{expected_url_suffix}.mp3" ), } await hass.async_block_till_done() @@ -526,7 +530,7 @@ async def test_deprecated_voice( assert mock_process_tts.call_args is not None assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["gender"] is None assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice assert mock_process_tts.call_args.kwargs["output"] == "mp3" issue = issue_registry.async_get_issue( @@ -542,3 +546,107 @@ async def test_deprecated_voice( "deprecated_voice": deprecated_voice, "replacement_voice": replacement_voice, } + + +@pytest.mark.parametrize( + ("data", "expected_url_suffix"), + [ + ({"platform": DOMAIN}, DOMAIN), + ({"engine_id": DOMAIN}, DOMAIN), + ({"engine_id": "tts.home_assistant_cloud"}, "tts.home_assistant_cloud"), + ], +) +async def test_deprecated_gender( + hass: HomeAssistant, + issue_registry: IssueRegistry, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + data: dict[str, Any], + expected_url_suffix: str, +) -> None: + """Test we create an issue when a deprecated gender is used for text-to-speech.""" + language = "zh-CN" + gender_option = "male" + mock_process_tts = AsyncMock( + return_value=b"", + ) + cloud.voice.process_tts = mock_process_tts + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + await cloud.login("test-user", "test-pass") + client = await hass_client() + + # Test without deprecated gender option. + url = "/api/tts_get_url" + data |= { + "message": "There is someone at the door.", + "language": language, + } + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ( + "http://example.local:8123/api/tts_proxy/" + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_{language.lower()}_5c97d21c48_{expected_url_suffix}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_{language.lower()}_5c97d21c48_{expected_url_suffix}.mp3" + ), + } + await hass.async_block_till_done() + + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["voice"] == "JennyNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue("cloud", "deprecated_gender") + assert issue is None + mock_process_tts.reset_mock() + + # Test with deprecated gender option. + data["options"] = {"gender": gender_option} + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ( + "http://example.local:8123/api/tts_proxy/" + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_{language.lower()}_5dded72256_{expected_url_suffix}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_{language.lower()}_5dded72256_{expected_url_suffix}.mp3" + ), + } + await hass.async_block_till_done() + + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["gender"] == gender_option + assert mock_process_tts.call_args.kwargs["voice"] == "JennyNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue("cloud", "deprecated_gender") + assert issue is not None + assert issue.breaks_in_ha_version == "2024.10.0" + assert issue.is_fixable is True + assert issue.is_persistent is True + assert issue.severity == IssueSeverity.WARNING + assert issue.translation_key == "deprecated_gender" + assert issue.translation_placeholders == { + "integration_name": "Home Assistant Cloud", + "deprecated_option": "gender", + "replacement_option": "voice", + }