Deprecate cloud tts gender (#112256)

* Deprecate cloud tts gender option

* Update http api and prefs

* Test migration of prefs to minor version 4

* Adjust breaking date

* Add test for bad voice in http api

* Flatten tts info

* Fix comments

* Fix comment date

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Clarify voice validator

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Martin Hjelmare 2024-03-20 09:42:40 +01:00 committed by GitHub
parent d31124d5d4
commit ac008a4c6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 304 additions and 78 deletions

View File

@ -33,7 +33,7 @@ PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
PREF_TTS_DEFAULT_VOICE = "tts_default_voice" PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
PREF_GOOGLE_CONNECTED = "google_connected" PREF_GOOGLE_CONNECTED = "google_connected"
PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable" 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_DISABLE_2FA = False
DEFAULT_ALEXA_REPORT_STATE = True DEFAULT_ALEXA_REPORT_STATE = True
DEFAULT_GOOGLE_REPORT_STATE = True DEFAULT_GOOGLE_REPORT_STATE = True

View File

@ -16,7 +16,7 @@ from aiohttp import web
import attr import attr
from hass_nabucasa import Cloud, auth, thingtalk from hass_nabucasa import Cloud, auth, thingtalk
from hass_nabucasa.const import STATE_DISCONNECTED 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 import voluptuous as vol
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
@ -426,6 +426,16 @@ async def websocket_subscription(
async_manage_legacy_subscription_issue(hass, data) 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 @_require_cloud_login
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
@ -436,7 +446,7 @@ async def websocket_subscription(
vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( 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, vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
} }
@ -840,5 +850,12 @@ def tts_info(
) -> None: ) -> None:
"""Fetch available tts info.""" """Fetch available tts info."""
connection.send_result( 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
]
},
) )

View File

@ -6,6 +6,8 @@ from collections.abc import Callable, Coroutine
from typing import Any from typing import Any
import uuid import uuid
from hass_nabucasa.voice import MAP_VOICE
from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import User from homeassistant.auth.models import User
from homeassistant.components import webhook from homeassistant.components import webhook
@ -48,7 +50,7 @@ from .const import (
STORAGE_KEY = DOMAIN STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1 STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 3 STORAGE_VERSION_MINOR = 4
ALEXA_SETTINGS_VERSION = 3 ALEXA_SETTINGS_VERSION = 3
GOOGLE_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 # 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 # assistant store if it's not been migrated by manual Google assistant
old_data.setdefault(PREF_GOOGLE_CONNECTED, await google_connected()) 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 return old_data
@ -332,7 +352,10 @@ class CloudPreferences:
@property @property
def tts_default_voice(self) -> tuple[str, str]: 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] return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return]
async def get_cloud_user(self) -> str: async def get_cloud_user(self) -> str:

View File

@ -24,6 +24,17 @@
} }
}, },
"issues": { "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": { "deprecated_tts_platform_config": {
"title": "The Cloud text-to-speech platform configuration is deprecated", "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." "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."

View File

@ -7,7 +7,7 @@ import logging
from typing import Any from typing import Any
from hass_nabucasa import Cloud 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 import voluptuous as vol
from homeassistant.components.tts import ( from homeassistant.components.tts import (
@ -97,17 +97,7 @@ async def async_get_engine(
) -> CloudProvider: ) -> CloudProvider:
"""Set up Cloud speech component.""" """Set up Cloud speech component."""
cloud: Cloud[CloudClient] = hass.data[DOMAIN] cloud: Cloud[CloudClient] = hass.data[DOMAIN]
cloud_provider = CloudProvider(cloud)
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)
if discovery_info is not None: if discovery_info is not None:
discovery_info["platform_loaded"].set() discovery_info["platform_loaded"].set()
return cloud_provider return cloud_provider
@ -134,11 +124,11 @@ class CloudTTSEntity(TextToSpeechEntity):
def __init__(self, cloud: Cloud[CloudClient]) -> None: def __init__(self, cloud: Cloud[CloudClient]) -> None:
"""Initialize cloud text-to-speech entity.""" """Initialize cloud text-to-speech entity."""
self.cloud = cloud 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: async def _sync_prefs(self, prefs: CloudPreferences) -> None:
"""Sync preferences.""" """Sync preferences."""
self._language, self._gender = prefs.tts_default_voice self._language, self._voice = prefs.tts_default_voice
@property @property
def default_language(self) -> str: def default_language(self) -> str:
@ -149,8 +139,8 @@ class CloudTTSEntity(TextToSpeechEntity):
def default_options(self) -> dict[str, Any]: def default_options(self) -> dict[str, Any]:
"""Return a dict include default options.""" """Return a dict include default options."""
return { return {
ATTR_GENDER: self._gender,
ATTR_AUDIO_OUTPUT: AudioOutput.MP3, ATTR_AUDIO_OUTPUT: AudioOutput.MP3,
ATTR_VOICE: self._voice,
} }
@property @property
@ -161,6 +151,7 @@ class CloudTTSEntity(TextToSpeechEntity):
@property @property
def supported_options(self) -> list[str]: def supported_options(self) -> list[str]:
"""Return list of supported options like voice, emotion.""" """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] return [ATTR_GENDER, ATTR_VOICE, ATTR_AUDIO_OUTPUT]
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
@ -184,6 +175,8 @@ class CloudTTSEntity(TextToSpeechEntity):
self, message: str, language: str, options: dict[str, Any] self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType: ) -> TtsAudioType:
"""Load TTS from Home Assistant Cloud.""" """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) original_voice: str | None = options.get(ATTR_VOICE)
voice = handle_deprecated_voice(self.hass, original_voice) voice = handle_deprecated_voice(self.hass, original_voice)
# Process TTS # Process TTS
@ -191,7 +184,7 @@ class CloudTTSEntity(TextToSpeechEntity):
data = await self.cloud.voice.process_tts( data = await self.cloud.voice.process_tts(
text=message, text=message,
language=language, language=language,
gender=options.get(ATTR_GENDER), gender=gender,
voice=voice, voice=voice,
output=options[ATTR_AUDIO_OUTPUT], output=options[ATTR_AUDIO_OUTPUT],
) )
@ -205,24 +198,16 @@ class CloudTTSEntity(TextToSpeechEntity):
class CloudProvider(Provider): class CloudProvider(Provider):
"""Home Assistant Cloud speech API provider.""" """Home Assistant Cloud speech API provider."""
def __init__( def __init__(self, cloud: Cloud[CloudClient]) -> None:
self, cloud: Cloud[CloudClient], language: str | None, gender: str | None
) -> None:
"""Initialize cloud provider.""" """Initialize cloud provider."""
self.cloud = cloud self.cloud = cloud
self.name = "Cloud" self.name = "Cloud"
self._language = language self._language, self._voice = cloud.client.prefs.tts_default_voice
self._gender = gender
if self._language is not None:
return
self._language, self._gender = cloud.client.prefs.tts_default_voice
cloud.client.prefs.async_listen_updates(self._sync_prefs) cloud.client.prefs.async_listen_updates(self._sync_prefs)
async def _sync_prefs(self, prefs: CloudPreferences) -> None: async def _sync_prefs(self, prefs: CloudPreferences) -> None:
"""Sync preferences.""" """Sync preferences."""
self._language, self._gender = prefs.tts_default_voice self._language, self._voice = prefs.tts_default_voice
@property @property
def default_language(self) -> str | None: def default_language(self) -> str | None:
@ -237,6 +222,7 @@ class CloudProvider(Provider):
@property @property
def supported_options(self) -> list[str]: def supported_options(self) -> list[str]:
"""Return list of supported options like voice, emotion.""" """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] return [ATTR_GENDER, ATTR_VOICE, ATTR_AUDIO_OUTPUT]
@callback @callback
@ -250,23 +236,25 @@ class CloudProvider(Provider):
def default_options(self) -> dict[str, Any]: def default_options(self) -> dict[str, Any]:
"""Return a dict include default options.""" """Return a dict include default options."""
return { return {
ATTR_GENDER: self._gender,
ATTR_AUDIO_OUTPUT: AudioOutput.MP3, ATTR_AUDIO_OUTPUT: AudioOutput.MP3,
ATTR_VOICE: self._voice,
} }
async def async_get_tts_audio( async def async_get_tts_audio(
self, message: str, language: str, options: dict[str, Any] self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType: ) -> TtsAudioType:
"""Load TTS from Home Assistant Cloud.""" """Load TTS from Home Assistant Cloud."""
original_voice: str | None = options.get(ATTR_VOICE)
assert self.hass is not None 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) voice = handle_deprecated_voice(self.hass, original_voice)
# Process TTS # Process TTS
try: try:
data = await self.cloud.voice.process_tts( data = await self.cloud.voice.process_tts(
text=message, text=message,
language=language, language=language,
gender=options.get(ATTR_GENDER), gender=gender,
voice=voice, voice=voice,
output=options[ATTR_AUDIO_OUTPUT], output=options[ATTR_AUDIO_OUTPUT],
) )
@ -277,6 +265,32 @@ class CloudProvider(Provider):
return (str(options[ATTR_AUDIO_OUTPUT].value), data) 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 @callback
def handle_deprecated_voice( def handle_deprecated_voice(
hass: HomeAssistant, hass: HomeAssistant,

View File

@ -2,13 +2,15 @@
from copy import deepcopy from copy import deepcopy
from http import HTTPStatus from http import HTTPStatus
import json
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
import aiohttp import aiohttp
from hass_nabucasa import thingtalk, voice from hass_nabucasa import thingtalk
from hass_nabucasa.auth import Unauthenticated, UnknownError from hass_nabucasa.auth import Unauthenticated, UnknownError
from hass_nabucasa.const import STATE_CONNECTED from hass_nabucasa.const import STATE_CONNECTED
from hass_nabucasa.voice import TTS_VOICES
import pytest import pytest
from homeassistant.components.alexa import errors as alexa_errors 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.cloud.const import DEFAULT_EXPOSED_DOMAINS, DOMAIN
from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.components.google_assistant.helpers import GoogleEntity
from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.websocket_api import ERR_INVALID_FORMAT
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -774,7 +777,7 @@ async def test_websocket_status(
"google_report_state": True, "google_report_state": True,
"remote_allow_remote_enable": True, "remote_allow_remote_enable": True,
"remote_enabled": False, "remote_enabled": False,
"tts_default_voice": ["en-US", "female"], "tts_default_voice": ["en-US", "JennyNeural"],
}, },
"alexa_entities": { "alexa_entities": {
"include_domains": [], "include_domains": [],
@ -896,14 +899,13 @@ async def test_websocket_update_preferences(
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
await client.send_json( await client.send_json_auto_id(
{ {
"id": 5,
"type": "cloud/update_prefs", "type": "cloud/update_prefs",
"alexa_enabled": False, "alexa_enabled": False,
"google_enabled": False, "google_enabled": False,
"google_secure_devices_pin": "1234", "google_secure_devices_pin": "1234",
"tts_default_voice": ["en-GB", "male"], "tts_default_voice": ["en-GB", "RyanNeural"],
"remote_allow_remote_enable": False, "remote_allow_remote_enable": False,
} }
) )
@ -914,7 +916,34 @@ async def test_websocket_update_preferences(
assert not cloud.client.prefs.alexa_enabled assert not cloud.client.prefs.alexa_enabled
assert cloud.client.prefs.google_secure_devices_pin == "1234" assert cloud.client.prefs.google_secure_devices_pin == "1234"
assert cloud.client.prefs.remote_allow_remote_enable is False 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( async def test_websocket_update_preferences_alexa_report_state(
@ -1596,24 +1625,23 @@ async def test_tts_info(
setup_cloud: None, setup_cloud: None,
) -> None: ) -> None:
"""Test that we can get TTS info.""" """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) client = await hass_ws_client(hass)
with patch.dict( await client.send_json_auto_id({"type": "cloud/tts/info"})
"homeassistant.components.cloud.http_api.MAP_VOICE", response = await client.receive_json()
{
("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()
assert response["success"] 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( @pytest.mark.parametrize(

View File

@ -1,13 +1,15 @@
"""Test Cloud preferences.""" """Test Cloud preferences."""
from typing import Any from typing import Any
from unittest.mock import ANY, patch from unittest.mock import ANY, MagicMock, patch
import pytest import pytest
from homeassistant.auth.const import GROUP_ID_ADMIN 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.components.cloud.prefs import STORAGE_KEY, CloudPreferences
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
async def test_set_username(hass: HomeAssistant) -> None: async def test_set_username(hass: HomeAssistant) -> None:
@ -149,3 +151,26 @@ async def test_import_google_assistant_settings(
prefs = CloudPreferences(hass) prefs = CloudPreferences(hass)
await prefs.async_initialize() await prefs.async_initialize()
assert prefs.google_connected == google_connected 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)

View File

@ -6,7 +6,7 @@ from http import HTTPStatus
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch 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 pytest
import voluptuous as vol import voluptuous as vol
@ -44,7 +44,11 @@ async def internal_url_mock(hass: HomeAssistant) -> None:
def test_default_exists() -> None: def test_default_exists() -> None:
"""Test our default language exists.""" """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: def test_schema() -> None:
@ -105,7 +109,7 @@ async def test_prefs_default_voice(
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done() 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] on_start_callback = cloud.register_on_start.call_args[0][0]
await on_start_callback() await on_start_callback()
@ -116,13 +120,13 @@ async def test_prefs_default_voice(
assert engine is not None assert engine is not None
# The platform config provider will be overridden by the discovery info provider. # The platform config provider will be overridden by the discovery info provider.
assert engine.default_language == "en-US" 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() await hass.async_block_till_done()
assert engine.default_language == "nl-NL" 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( async def test_deprecated_platform_config(
@ -224,11 +228,11 @@ async def test_get_tts_audio(
"url": ( "url": (
"http://example.local:8123/api/tts_proxy/" "http://example.local:8123/api/tts_proxy/"
"42f18378fd4393d18c8dd11d03fa9563c1e54491" "42f18378fd4393d18c8dd11d03fa9563c1e54491"
f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" f"_en-us_5c97d21c48_{expected_url_suffix}.mp3"
), ),
"path": ( "path": (
"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" "/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() 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 is not None
assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." 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["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" assert mock_process_tts.call_args.kwargs["output"] == "mp3"
@ -276,11 +280,11 @@ async def test_get_tts_audio_logged_out(
"url": ( "url": (
"http://example.local:8123/api/tts_proxy/" "http://example.local:8123/api/tts_proxy/"
"42f18378fd4393d18c8dd11d03fa9563c1e54491" "42f18378fd4393d18c8dd11d03fa9563c1e54491"
f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" f"_en-us_5c97d21c48_{expected_url_suffix}.mp3"
), ),
"path": ( "path": (
"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" "/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() 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 is not None
assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." 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["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" assert mock_process_tts.call_args.kwargs["output"] == "mp3"
@ -340,11 +344,11 @@ async def test_tts_entity(
"url": ( "url": (
"http://example.local:8123/api/tts_proxy/" "http://example.local:8123/api/tts_proxy/"
"42f18378fd4393d18c8dd11d03fa9563c1e54491" "42f18378fd4393d18c8dd11d03fa9563c1e54491"
f"_en-us_e09b5a0968_{entity_id}.mp3" f"_en-us_5c97d21c48_{entity_id}.mp3"
), ),
"path": ( "path": (
"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
f"_en-us_e09b5a0968_{entity_id}.mp3" f"_en-us_5c97d21c48_{entity_id}.mp3"
), ),
} }
await hass.async_block_till_done() 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 is not None
assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." 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["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" assert mock_process_tts.call_args.kwargs["output"] == "mp3"
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
@ -480,11 +484,11 @@ async def test_deprecated_voice(
"url": ( "url": (
"http://example.local:8123/api/tts_proxy/" "http://example.local:8123/api/tts_proxy/"
"42f18378fd4393d18c8dd11d03fa9563c1e54491" "42f18378fd4393d18c8dd11d03fa9563c1e54491"
f"_{language.lower()}_1c4ec2f170_{expected_url_suffix}.mp3" f"_{language.lower()}_87567e3e29_{expected_url_suffix}.mp3"
), ),
"path": ( "path": (
"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" "/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() 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 is not None
assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." 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["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["voice"] == replacement_voice
assert mock_process_tts.call_args.kwargs["output"] == "mp3" assert mock_process_tts.call_args.kwargs["output"] == "mp3"
issue = issue_registry.async_get_issue( issue = issue_registry.async_get_issue(
@ -513,11 +517,11 @@ async def test_deprecated_voice(
"url": ( "url": (
"http://example.local:8123/api/tts_proxy/" "http://example.local:8123/api/tts_proxy/"
"42f18378fd4393d18c8dd11d03fa9563c1e54491" "42f18378fd4393d18c8dd11d03fa9563c1e54491"
f"_{language.lower()}_a1c3b0ac0e_{expected_url_suffix}.mp3" f"_{language.lower()}_13646b7d32_{expected_url_suffix}.mp3"
), ),
"path": ( "path": (
"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" "/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() 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 is not None
assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." 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["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["voice"] == replacement_voice
assert mock_process_tts.call_args.kwargs["output"] == "mp3" assert mock_process_tts.call_args.kwargs["output"] == "mp3"
issue = issue_registry.async_get_issue( issue = issue_registry.async_get_issue(
@ -542,3 +546,107 @@ async def test_deprecated_voice(
"deprecated_voice": deprecated_voice, "deprecated_voice": deprecated_voice,
"replacement_voice": replacement_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",
}