From 5fefa606b6c3aaf38e7555e18037c18adb645a9b Mon Sep 17 00:00:00 2001 From: Simon <80467011+sorgfresser@users.noreply.github.com> Date: Wed, 31 Jul 2024 21:31:09 +0200 Subject: [PATCH] Add ElevenLabs text-to-speech integration (#115645) * Add ElevenLabs text-to-speech integration * Remove commented out code * Use model_id instead of model_name for elevenlabs api * Apply suggestions from code review Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> * Use async client instead of sync * Add ElevenLabs code owner * Apply suggestions from code review Co-authored-by: Paulus Schoutsen * Set entity title to voice * Rename to elevenlabs * Apply suggestions from code review Co-authored-by: Paulus Schoutsen * Allow multiple voices and options flow * Sort default voice at beginning * Rework config flow to include default model and reloading on options flow * Add error to strings * Add ElevenLabsData and suggestions from code review * Shorten options and config flow * Fix comments * Fix comments * Add wip * Fix * Cleanup * Bump elevenlabs version * Add data description * Fix --------- Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> Co-authored-by: Paulus Schoutsen Co-authored-by: Michael Hansen Co-authored-by: Joostlek Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/elevenlabs/__init__.py | 71 +++++ .../components/elevenlabs/config_flow.py | 145 ++++++++++ homeassistant/components/elevenlabs/const.py | 7 + .../components/elevenlabs/manifest.json | 11 + .../components/elevenlabs/strings.json | 31 ++ homeassistant/components/elevenlabs/tts.py | 116 ++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/elevenlabs/__init__.py | 1 + tests/components/elevenlabs/conftest.py | 65 +++++ tests/components/elevenlabs/const.py | 52 ++++ .../components/elevenlabs/test_config_flow.py | 94 ++++++ tests/components/elevenlabs/test_tts.py | 270 ++++++++++++++++++ 18 files changed, 889 insertions(+) create mode 100644 homeassistant/components/elevenlabs/__init__.py create mode 100644 homeassistant/components/elevenlabs/config_flow.py create mode 100644 homeassistant/components/elevenlabs/const.py create mode 100644 homeassistant/components/elevenlabs/manifest.json create mode 100644 homeassistant/components/elevenlabs/strings.json create mode 100644 homeassistant/components/elevenlabs/tts.py create mode 100644 tests/components/elevenlabs/__init__.py create mode 100644 tests/components/elevenlabs/conftest.py create mode 100644 tests/components/elevenlabs/const.py create mode 100644 tests/components/elevenlabs/test_config_flow.py create mode 100644 tests/components/elevenlabs/test_tts.py diff --git a/.strict-typing b/.strict-typing index a4f6d198d97..02d9968d247 100644 --- a/.strict-typing +++ b/.strict-typing @@ -168,6 +168,7 @@ homeassistant.components.ecowitt.* homeassistant.components.efergy.* homeassistant.components.electrasmart.* homeassistant.components.electric_kiwi.* +homeassistant.components.elevenlabs.* homeassistant.components.elgato.* homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* diff --git a/CODEOWNERS b/CODEOWNERS index 4f8c03fe90f..e90def993d2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -376,6 +376,8 @@ build.json @home-assistant/supervisor /tests/components/electrasmart/ @jafar-atili /homeassistant/components/electric_kiwi/ @mikey0000 /tests/components/electric_kiwi/ @mikey0000 +/homeassistant/components/elevenlabs/ @sorgfresser +/tests/components/elevenlabs/ @sorgfresser /homeassistant/components/elgato/ @frenck /tests/components/elgato/ @frenck /homeassistant/components/elkm1/ @gwww @bdraco diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py new file mode 100644 index 00000000000..99cddd783e2 --- /dev/null +++ b/homeassistant/components/elevenlabs/__init__.py @@ -0,0 +1,71 @@ +"""The ElevenLabs text-to-speech integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from elevenlabs import Model +from elevenlabs.client import AsyncElevenLabs +from elevenlabs.core import ApiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError + +from .const import CONF_MODEL + +PLATFORMS: list[Platform] = [Platform.TTS] + + +async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None: + """Get ElevenLabs model from their API by the model_id.""" + models = await client.models.get_all() + for maybe_model in models: + if maybe_model.model_id == model_id: + return maybe_model + return None + + +@dataclass(kw_only=True, slots=True) +class ElevenLabsData: + """ElevenLabs data type.""" + + client: AsyncElevenLabs + model: Model + + +type EleventLabsConfigEntry = ConfigEntry[ElevenLabsData] + + +async def async_setup_entry(hass: HomeAssistant, entry: EleventLabsConfigEntry) -> bool: + """Set up ElevenLabs text-to-speech from a config entry.""" + entry.add_update_listener(update_listener) + client = AsyncElevenLabs(api_key=entry.data[CONF_API_KEY]) + model_id = entry.options[CONF_MODEL] + try: + model = await get_model_by_id(client, model_id) + except ApiError as err: + raise ConfigEntryError("Auth failed") from err + + if model is None or (not model.languages): + raise ConfigEntryError("Model could not be resolved") + + entry.runtime_data = ElevenLabsData(client=client, model=model) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: EleventLabsConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener( + hass: HomeAssistant, config_entry: EleventLabsConfigEntry +) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py new file mode 100644 index 00000000000..cf04304510a --- /dev/null +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -0,0 +1,145 @@ +"""Config flow for ElevenLabs text-to-speech integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from elevenlabs.client import AsyncElevenLabs +from elevenlabs.core import ApiError +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) + +from .const import CONF_MODEL, CONF_VOICE, DEFAULT_MODEL, DOMAIN + +USER_STEP_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +_LOGGER = logging.getLogger(__name__) + + +async def get_voices_models(api_key: str) -> tuple[dict[str, str], dict[str, str]]: + """Get available voices and models as dicts.""" + client = AsyncElevenLabs(api_key=api_key) + voices = (await client.voices.get_all()).voices + models = await client.models.get_all() + voices_dict = { + voice.voice_id: voice.name + for voice in sorted(voices, key=lambda v: v.name or "") + if voice.name + } + models_dict = { + model.model_id: model.name + for model in sorted(models, key=lambda m: m.name or "") + if model.name and model.can_do_text_to_speech + } + return voices_dict, models_dict + + +class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ElevenLabs text-to-speech.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + voices, _ = await get_voices_models(user_input[CONF_API_KEY]) + except ApiError: + errors["base"] = "invalid_api_key" + else: + return self.async_create_entry( + title="ElevenLabs", + data=user_input, + options={CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: list(voices)[0]}, + ) + return self.async_show_form( + step_id="user", data_schema=USER_STEP_SCHEMA, errors=errors + ) + + @staticmethod + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlow: + """Create the options flow.""" + return ElevenLabsOptionsFlow(config_entry) + + +class ElevenLabsOptionsFlow(OptionsFlowWithConfigEntry): + """ElevenLabs options flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + super().__init__(config_entry) + self.api_key: str = self.config_entry.data[CONF_API_KEY] + # id -> name + self.voices: dict[str, str] = {} + self.models: dict[str, str] = {} + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if not self.voices or not self.models: + self.voices, self.models = await get_voices_models(self.api_key) + + assert self.models and self.voices + + if user_input is not None: + return self.async_create_entry( + title="ElevenLabs", + data=user_input, + ) + + schema = self.elevenlabs_config_option_schema() + return self.async_show_form( + step_id="init", + data_schema=schema, + ) + + def elevenlabs_config_option_schema(self) -> vol.Schema: + """Elevenlabs options schema.""" + return self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required( + CONF_MODEL, + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(label=model_name, value=model_id) + for model_id, model_name in self.models.items() + ] + ) + ), + vol.Required( + CONF_VOICE, + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(label=voice_name, value=voice_id) + for voice_id, voice_name in self.voices.items() + ] + ) + ), + } + ), + self.options, + ) diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py new file mode 100644 index 00000000000..c0fc3c7b1b0 --- /dev/null +++ b/homeassistant/components/elevenlabs/const.py @@ -0,0 +1,7 @@ +"""Constants for the ElevenLabs text-to-speech integration.""" + +CONF_VOICE = "voice" +CONF_MODEL = "model" +DOMAIN = "elevenlabs" + +DEFAULT_MODEL = "eleven_multilingual_v2" diff --git a/homeassistant/components/elevenlabs/manifest.json b/homeassistant/components/elevenlabs/manifest.json new file mode 100644 index 00000000000..968ea7b688a --- /dev/null +++ b/homeassistant/components/elevenlabs/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "elevenlabs", + "name": "ElevenLabs", + "codeowners": ["@sorgfresser"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/elevenlabs", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["elevenlabs"], + "requirements": ["elevenlabs==1.6.1"] +} diff --git a/homeassistant/components/elevenlabs/strings.json b/homeassistant/components/elevenlabs/strings.json new file mode 100644 index 00000000000..16b40137090 --- /dev/null +++ b/homeassistant/components/elevenlabs/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "Your Elevenlabs API key." + } + } + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "voice": "Voice", + "model": "Model" + }, + "data_description": { + "voice": "Voice to use for the TTS.", + "model": "ElevenLabs model to use. Please note that not all models support all languages equally well." + } + } + } + } +} diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py new file mode 100644 index 00000000000..35ba6053cd8 --- /dev/null +++ b/homeassistant/components/elevenlabs/tts.py @@ -0,0 +1,116 @@ +"""Support for the ElevenLabs text-to-speech service.""" + +from __future__ import annotations + +import logging +from typing import Any + +from elevenlabs.client import AsyncElevenLabs +from elevenlabs.core import ApiError +from elevenlabs.types import Model, Voice as ElevenLabsVoice + +from homeassistant.components.tts import ( + ATTR_VOICE, + TextToSpeechEntity, + TtsAudioType, + Voice, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EleventLabsConfigEntry +from .const import CONF_VOICE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: EleventLabsConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up ElevenLabs tts platform via config entry.""" + client = config_entry.runtime_data.client + voices = (await client.voices.get_all()).voices + default_voice_id = config_entry.options[CONF_VOICE] + async_add_entities( + [ + ElevenLabsTTSEntity( + client, + config_entry.runtime_data.model, + voices, + default_voice_id, + config_entry.entry_id, + config_entry.title, + ) + ] + ) + + +class ElevenLabsTTSEntity(TextToSpeechEntity): + """The ElevenLabs API entity.""" + + _attr_supported_options = [ATTR_VOICE] + + def __init__( + self, + client: AsyncElevenLabs, + model: Model, + voices: list[ElevenLabsVoice], + default_voice_id: str, + entry_id: str, + title: str, + ) -> None: + """Init ElevenLabs TTS service.""" + self._client = client + self._model = model + self._default_voice_id = default_voice_id + self._voices = sorted( + (Voice(v.voice_id, v.name) for v in voices if v.name), + key=lambda v: v.name, + ) + # Default voice first + voice_indices = [ + idx for idx, v in enumerate(self._voices) if v.voice_id == default_voice_id + ] + if voice_indices: + self._voices.insert(0, self._voices.pop(voice_indices[0])) + self._attr_unique_id = entry_id + self._attr_name = title + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + manufacturer="ElevenLabs", + model=model.name, + entry_type=DeviceEntryType.SERVICE, + ) + self._attr_supported_languages = [ + lang.language_id for lang in self._model.languages or [] + ] + self._attr_default_language = self.supported_languages[0] + + def async_get_supported_voices(self, language: str) -> list[Voice]: + """Return a list of supported voices for a language.""" + return self._voices + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine.""" + _LOGGER.debug("Getting TTS audio for %s", message) + _LOGGER.debug("Options: %s", options) + voice_id = options[ATTR_VOICE] + try: + audio = await self._client.generate( + text=message, + voice=voice_id, + model=self._model.model_id, + ) + bytes_combined = b"".join([byte_seg async for byte_seg in audio]) + except ApiError as exc: + _LOGGER.warning( + "Error during processing of TTS request %s", exc, exc_info=True + ) + raise HomeAssistantError(exc) from exc + return "mp3", bytes_combined diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d350a58f3c6..0c37cf9c412 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -150,6 +150,7 @@ FLOWS = { "efergy", "electrasmart", "electric_kiwi", + "elevenlabs", "elgato", "elkm1", "elmax", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 597cd8be936..3cc3ea71df9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1516,6 +1516,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "elevenlabs": { + "name": "ElevenLabs", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "elgato": { "name": "Elgato", "integrations": { diff --git a/mypy.ini b/mypy.ini index dd7904d798b..0f4f8907612 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1436,6 +1436,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.elevenlabs.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.elgato.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 2cae4548956..d00605c9893 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -779,6 +779,9 @@ ecoaliface==0.4.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 +# homeassistant.components.elevenlabs +elevenlabs==1.6.1 + # homeassistant.components.elgato elgato==5.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d2f345cf1a..fd200794990 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -660,6 +660,9 @@ easyenergy==2.1.2 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 +# homeassistant.components.elevenlabs +elevenlabs==1.6.1 + # homeassistant.components.elgato elgato==5.1.2 diff --git a/tests/components/elevenlabs/__init__.py b/tests/components/elevenlabs/__init__.py new file mode 100644 index 00000000000..261286f04f7 --- /dev/null +++ b/tests/components/elevenlabs/__init__.py @@ -0,0 +1 @@ +"""Tests for the ElevenLabs integration.""" diff --git a/tests/components/elevenlabs/conftest.py b/tests/components/elevenlabs/conftest.py new file mode 100644 index 00000000000..13eb022243f --- /dev/null +++ b/tests/components/elevenlabs/conftest.py @@ -0,0 +1,65 @@ +"""Common fixtures for the ElevenLabs text-to-speech tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from elevenlabs.core import ApiError +from elevenlabs.types import GetVoicesResponse +import pytest + +from homeassistant.components.elevenlabs.const import CONF_MODEL, CONF_VOICE +from homeassistant.const import CONF_API_KEY + +from .const import MOCK_MODELS, MOCK_VOICES + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.elevenlabs.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_async_client() -> Generator[AsyncMock, None, None]: + """Override async ElevenLabs client.""" + client_mock = AsyncMock() + client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) + client_mock.models.get_all.return_value = MOCK_MODELS + with patch( + "elevenlabs.client.AsyncElevenLabs", return_value=client_mock + ) as mock_async_client: + yield mock_async_client + + +@pytest.fixture +def mock_async_client_fail() -> Generator[AsyncMock, None, None]: + """Override async ElevenLabs client.""" + with patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + return_value=AsyncMock(), + ) as mock_async_client: + mock_async_client.side_effect = ApiError + yield mock_async_client + + +@pytest.fixture +def mock_entry() -> MockConfigEntry: + """Mock a config entry.""" + entry = MockConfigEntry( + domain="elevenlabs", + data={ + CONF_API_KEY: "api_key", + }, + options={CONF_MODEL: "model1", CONF_VOICE: "voice1"}, + ) + entry.models = { + "model1": "model1", + } + + entry.voices = {"voice1": "voice1"} + return entry diff --git a/tests/components/elevenlabs/const.py b/tests/components/elevenlabs/const.py new file mode 100644 index 00000000000..e16e1fd1334 --- /dev/null +++ b/tests/components/elevenlabs/const.py @@ -0,0 +1,52 @@ +"""Constants for the Testing of the ElevenLabs text-to-speech integration.""" + +from elevenlabs.types import LanguageResponse, Model, Voice + +from homeassistant.components.elevenlabs.const import DEFAULT_MODEL + +MOCK_VOICES = [ + Voice( + voice_id="voice1", + name="Voice 1", + ), + Voice( + voice_id="voice2", + name="Voice 2", + ), +] + +MOCK_MODELS = [ + Model( + model_id="model1", + name="Model 1", + can_do_text_to_speech=True, + languages=[ + LanguageResponse(language_id="en", name="English"), + LanguageResponse(language_id="de", name="German"), + LanguageResponse(language_id="es", name="Spanish"), + LanguageResponse(language_id="ja", name="Japanese"), + ], + ), + Model( + model_id="model2", + name="Model 2", + can_do_text_to_speech=True, + languages=[ + LanguageResponse(language_id="en", name="English"), + LanguageResponse(language_id="de", name="German"), + LanguageResponse(language_id="es", name="Spanish"), + LanguageResponse(language_id="ja", name="Japanese"), + ], + ), + Model( + model_id=DEFAULT_MODEL, + name=DEFAULT_MODEL, + can_do_text_to_speech=True, + languages=[ + LanguageResponse(language_id="en", name="English"), + LanguageResponse(language_id="de", name="German"), + LanguageResponse(language_id="es", name="Spanish"), + LanguageResponse(language_id="ja", name="Japanese"), + ], + ), +] diff --git a/tests/components/elevenlabs/test_config_flow.py b/tests/components/elevenlabs/test_config_flow.py new file mode 100644 index 00000000000..853c49d48ff --- /dev/null +++ b/tests/components/elevenlabs/test_config_flow.py @@ -0,0 +1,94 @@ +"""Test the ElevenLabs text-to-speech config flow.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.elevenlabs.const import ( + CONF_MODEL, + CONF_VOICE, + DEFAULT_MODEL, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_step( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client: AsyncMock, +) -> None: + """Test user step create entry result.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ElevenLabs" + assert result["data"] == { + "api_key": "api_key", + } + assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + + mock_setup_entry.assert_called_once() + + +async def test_invalid_api_key( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_async_client_fail: AsyncMock +) -> None: + """Test user step with invalid api key.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] + + mock_setup_entry.assert_not_called() + + +async def test_options_flow_init( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client: AsyncMock, + mock_entry: MockConfigEntry, +) -> None: + """Test options flow init.""" + mock_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_MODEL: "model1", CONF_VOICE: "voice1"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_entry.options == {CONF_MODEL: "model1", CONF_VOICE: "voice1"} + + mock_setup_entry.assert_called_once() diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py new file mode 100644 index 00000000000..7fa289f24ed --- /dev/null +++ b/tests/components/elevenlabs/test_tts.py @@ -0,0 +1,270 @@ +"""Tests for the ElevenLabs TTS entity.""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import Any +from unittest.mock import AsyncMock, patch + +from elevenlabs.core import ApiError +from elevenlabs.types import GetVoicesResponse +import pytest + +from homeassistant.components import tts +from homeassistant.components.elevenlabs.const import CONF_MODEL, CONF_VOICE, DOMAIN +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, +) +from homeassistant.config import async_process_ha_core_config +from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY +from homeassistant.core import HomeAssistant, ServiceCall + +from .const import MOCK_MODELS, MOCK_VOICES + +from tests.common import MockConfigEntry, async_mock_service +from tests.components.tts.common import retrieve_media +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): + """Mock writing tags.""" + + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + +@pytest.fixture +async def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Mock media player calls.""" + return async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + +@pytest.fixture(autouse=True) +async def setup_internal_url(hass: HomeAssistant) -> None: + """Set up internal url.""" + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"} + ) + + +@pytest.fixture(name="setup") +async def setup_fixture( + hass: HomeAssistant, + config_data: dict[str, Any], + config_options: dict[str, Any], + request: pytest.FixtureRequest, + mock_async_client: AsyncMock, +) -> AsyncMock: + """Set up the test environment.""" + if request.param == "mock_config_entry_setup": + await mock_config_entry_setup(hass, config_data, config_options) + else: + raise RuntimeError("Invalid setup fixture") + + await hass.async_block_till_done() + return mock_async_client + + +@pytest.fixture(name="config_data") +def config_data_fixture() -> dict[str, Any]: + """Return config data.""" + return {} + + +@pytest.fixture(name="config_options") +def config_options_fixture() -> dict[str, Any]: + """Return config options.""" + return {} + + +async def mock_config_entry_setup( + hass: HomeAssistant, config_data: dict[str, Any], config_options: dict[str, Any] +) -> None: + """Mock config entry setup.""" + default_config_data = { + CONF_API_KEY: "api_key", + } + default_config_options = { + CONF_VOICE: "voice1", + CONF_MODEL: "model1", + } + config_entry = MockConfigEntry( + domain=DOMAIN, + data=default_config_data | config_data, + options=default_config_options | config_options, + ) + config_entry.add_to_hass(hass) + client_mock = AsyncMock() + client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) + client_mock.models.get_all.return_value = MOCK_MODELS + with patch( + "homeassistant.components.elevenlabs.AsyncElevenLabs", return_value=client_mock + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + +@pytest.mark.parametrize( + "config_data", + [ + {}, + {tts.CONF_LANG: "de"}, + {tts.CONF_LANG: "en"}, + {tts.CONF_LANG: "ja"}, + {tts.CONF_LANG: "es"}, + ], +) +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test tts service.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._client.generate.reset_mock() + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + + tts_entity._client.generate.assert_called_once_with( + text="There is a person at the front door.", voice="voice2", model="model1" + ) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "de", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "es", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_lang_config( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test service call say with other langcodes in the config.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._client.generate.reset_mock() + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + + tts_entity._client.generate.assert_called_once_with( + text="There is a person at the front door.", voice="voice1", model="model1" + ) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_error( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test service call say with http response 400.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._client.generate.reset_mock() + tts_entity._client.generate.side_effect = ApiError + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.NOT_FOUND + ) + + tts_entity._client.generate.assert_called_once_with( + text="There is a person at the front door.", voice="voice1", model="model1" + )