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_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

View File

@ -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
]
},
)

View File

@ -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:

View File

@ -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."

View File

@ -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,

View File

@ -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(

View File

@ -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)

View File

@ -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",
}