"""The tests for the TTS component.""" import asyncio from http import HTTPStatus from typing import Any from unittest.mock import patch import pytest import voluptuous as vol from homeassistant.components import media_source, tts from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, MediaType, ) from homeassistant.components.media_source import Unresolvable from homeassistant.components.tts.legacy import _valid_base_url from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util.network import normalize_url from .common import MockProvider, MockTTS from tests.common import ( MockModule, assert_setup_component, async_mock_service, mock_integration, mock_platform, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator ORIG_WRITE_TAGS = tts.SpeechManager.write_tags async def get_media_source_url(hass: HomeAssistant, media_content_id: str) -> str: """Get the media source url.""" if media_source.DOMAIN not in hass.config.components: assert await async_setup_component(hass, media_source.DOMAIN, {}) resolved = await media_source.async_resolve_media(hass, media_content_id, None) return resolved.url @pytest.fixture def mock_provider() -> MockProvider: """Test TTS provider.""" return MockProvider("en_US") @pytest.fixture async def setup_tts(hass: HomeAssistant, mock_tts: None) -> None: """Mock TTS.""" assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) async def test_setup_component(hass: HomeAssistant, setup_tts) -> None: """Set up a TTS platform with defaults.""" assert hass.services.has_service(tts.DOMAIN, "test_say") assert hass.services.has_service(tts.DOMAIN, "clear_cache") assert f"{tts.DOMAIN}.test" in hass.config.components async def test_setup_component_no_access_cache_folder( hass: HomeAssistant, mock_init_cache_dir, mock_tts ) -> None: """Set up a TTS platform with defaults.""" config = {tts.DOMAIN: {"platform": "test"}} mock_init_cache_dir.side_effect = OSError(2, "No access") assert not await async_setup_component(hass, tts.DOMAIN, config) assert not hass.services.has_service(tts.DOMAIN, "test_say") assert not hass.services.has_service(tts.DOMAIN, "clear_cache") async def test_setup_component_and_test_service( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", }, blocking=True, ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_ANNOUNCE] is True assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_config_language( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) # Language de is matched with de_DE config = {tts.DOMAIN: {"platform": "test", "language": "de"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", }, blocking=True, ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_config_language_special( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: """Set up a TTS platform and call service with extend language.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test", "language": "en_US"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", }, blocking=True, ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_wrong_conf_language( hass: HomeAssistant, mock_tts ) -> None: """Set up a TTS platform and call service with wrong config.""" config = {tts.DOMAIN: {"platform": "test", "language": "ru"}} with assert_setup_component(0, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) async def test_setup_component_and_test_service_with_service_language( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) # Language de is matched to de_DE await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", tts.ATTR_LANGUAGE: "de", }, blocking=True, ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_test.mp3" ).is_file() async def test_setup_component_test_service_with_wrong_service_language( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) with pytest.raises(HomeAssistantError): await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", tts.ATTR_LANGUAGE: "lang", }, blocking=True, ) assert len(calls) == 0 assert not ( empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_service_options( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: """Set up a TTS platform and call service with options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) # Language de is matched with de_DE await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", tts.ATTR_LANGUAGE: "de", tts.ATTR_OPTIONS: {"voice": "alex", "age": 5}, }, blocking=True, ) opt_hash = tts._hash_options({"voice": "alex", "age": 5}) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_{opt_hash}_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_with_service_options_def( hass: HomeAssistant, empty_cache_dir ) -> None: """Set up a TTS platform and call service with default options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test"}} class MockProviderWithDefaults(MockProvider): """Mock provider with default options.""" @property def default_options(self): return {"voice": "alex"} mock_integration(hass, MockModule(domain="test")) mock_platform(hass, "test.tts", MockTTS(MockProviderWithDefaults)) with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) # Language de is matched with de_DE await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", tts.ATTR_LANGUAGE: "de", }, blocking=True, ) opt_hash = tts._hash_options({"voice": "alex"}) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_{opt_hash}_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_with_service_options_def_2( hass: HomeAssistant, empty_cache_dir ) -> None: """Set up a TTS platform and call service with default options. This tests merging default and user provided options. """ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test"}} class MockProviderWithDefaults(MockProvider): """Mock provider with default options.""" @property def default_options(self): return {"voice": "alex"} mock_integration(hass, MockModule(domain="test")) mock_platform(hass, "test.tts", MockTTS(MockProviderWithDefaults)) with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) # Language de is matched with de_DE await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", tts.ATTR_LANGUAGE: "de", tts.ATTR_OPTIONS: {"age": 5}, }, blocking=True, ) opt_hash = tts._hash_options({"voice": "alex", "age": 5}) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_{opt_hash}_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_service_options_wrong( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: """Set up a TTS platform and call service with wrong options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) # Language de is matched with de_DE with pytest.raises(HomeAssistantError): await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", tts.ATTR_LANGUAGE: "de", tts.ATTR_OPTIONS: {"speed": 1}, }, blocking=True, ) opt_hash = tts._hash_options({"speed": 1}) assert len(calls) == 0 await hass.async_block_till_done() assert not ( empty_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_base_url_set( hass: HomeAssistant, mock_tts ) -> None: """Set up a TTS platform with ``base_url`` set and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test", "base_url": "http://fnord"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", }, blocking=True, ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == "http://fnord" "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" "_en-us_-_test.mp3" ) async def test_setup_component_and_test_service_clear_cache( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: """Set up a TTS platform and call service clear cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", }, blocking=True, ) # To make sure the file is persisted assert len(calls) == 1 await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) await hass.async_block_till_done() assert ( empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ).is_file() await hass.services.async_call( tts.DOMAIN, tts.SERVICE_CLEAR_CACHE, {}, blocking=True ) assert not ( empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_receive_voice( hass: HomeAssistant, mock_provider: MockProvider, hass_client: ClientSessionGenerator, mock_tts, ) -> None: """Set up a TTS platform and call service and receive voice.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) message = "There is someone at the door." await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: message, }, blocking=True, ) assert len(calls) == 1 url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) client = await hass_client() req = await client.get(url) # Language en is matched with en_US _, tts_data = mock_provider.get_tts_audio("bla", "en") assert tts_data is not None tts_data = tts.SpeechManager.write_tags( "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3", tts_data, mock_provider, message, "en", None, ) assert req.status == HTTPStatus.OK assert await req.read() == tts_data extension, data = await tts.async_get_media_source_audio( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) assert extension == "mp3" assert tts_data == data async def test_setup_component_and_test_service_with_receive_voice_german( hass: HomeAssistant, mock_provider: MockProvider, hass_client: ClientSessionGenerator, mock_tts, ) -> None: """Set up a TTS platform and call service and receive voice.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) # Language de is matched with de_DE config = {tts.DOMAIN: {"platform": "test", "language": "de"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", }, blocking=True, ) assert len(calls) == 1 url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) client = await hass_client() req = await client.get(url) _, tts_data = mock_provider.get_tts_audio("bla", "de") assert tts_data is not None tts_data = tts.SpeechManager.write_tags( "42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_test.mp3", tts_data, mock_provider, "There is someone at the door.", "de", None, ) assert req.status == HTTPStatus.OK assert await req.read() == tts_data async def test_setup_component_and_web_view_wrong_file( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: """Set up a TTS platform and receive wrong file from web.""" config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" req = await client.get(url) assert req.status == HTTPStatus.NOT_FOUND async def test_setup_component_and_web_view_wrong_filename( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: """Set up a TTS platform and receive wrong filename from web.""" config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() url = "/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd_en-us_-_test.mp3" req = await client.get(url) assert req.status == HTTPStatus.NOT_FOUND async def test_setup_component_test_without_cache( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: """Set up a TTS platform without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test", "cache": False}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", }, blocking=True, ) assert len(calls) == 1 await hass.async_block_till_done() assert not ( empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ).is_file() async def test_setup_component_test_with_cache_call_service_without_cache( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test", "cache": True}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", tts.ATTR_CACHE: False, }, blocking=True, ) assert len(calls) == 1 await hass.async_block_till_done() assert not ( empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ).is_file() async def test_setup_component_test_with_cache_dir( hass: HomeAssistant, empty_cache_dir, mock_provider: MockProvider ) -> None: """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) # Language en is matched with en_US _, tts_data = mock_provider.get_tts_audio("bla", "en") assert tts_data is not None cache_file = ( empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) with open(cache_file, "wb") as voice_file: voice_file.write(tts_data) config = {tts.DOMAIN: {"platform": "test", "cache": True}} class MockProviderBoom(MockProvider): """Mock provider that blows up.""" def get_tts_audio( self, message: str, language: str, options: dict[str, Any] | None = None ) -> tts.TtsAudioType: """Load TTS dat.""" # This should not be called, data should be fetched from cache raise Exception("Boom!") # pylint: disable=broad-exception-raised mock_integration(hass, MockModule(domain="test")) mock_platform(hass, "test.tts", MockTTS(MockProviderBoom)) with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", }, blocking=True, ) assert len(calls) == 1 assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) await hass.async_block_till_done() async def test_setup_component_test_with_error_on_get_tts(hass: HomeAssistant) -> None: """Set up a TTS platform with wrong get_tts_audio.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) config = {tts.DOMAIN: {"platform": "test"}} class MockProviderEmpty(MockProvider): """Mock provider with empty get_tts_audio.""" def get_tts_audio( self, message: str, language: str, options: dict[str, Any] | None = None ) -> tts.TtsAudioType: """Load TTS dat.""" return (None, None) mock_integration(hass, MockModule(domain="test")) mock_platform(hass, "test.tts", MockTTS(MockProviderEmpty)) with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", }, blocking=True, ) assert len(calls) == 1 with pytest.raises(Unresolvable): await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) async def test_setup_component_load_cache_retrieve_without_mem_cache( hass: HomeAssistant, mock_provider: MockProvider, empty_cache_dir, hass_client: ClientSessionGenerator, mock_tts, ) -> None: """Set up component and load cache and get without mem cache.""" # Language en is matched with en_US _, tts_data = mock_provider.get_tts_audio("bla", "en") assert tts_data is not None cache_file = ( empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) with open(cache_file, "wb") as voice_file: voice_file.write(tts_data) config = {tts.DOMAIN: {"platform": "test", "cache": True}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" req = await client.get(url) assert req.status == HTTPStatus.OK assert await req.read() == tts_data async def test_setup_component_and_web_get_url( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: """Set up a TTS platform and receive file from web.""" config = {tts.DOMAIN: {"platform": "test"}} await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() url = "/api/tts_get_url" data = {"platform": "test", "message": "There is someone at the door."} 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_en-us_-_test.mp3", "path": "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3", } async def test_setup_component_and_web_get_url_bad_config( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: """Set up a TTS platform and receive wrong file from web.""" config = {tts.DOMAIN: {"platform": "test"}} await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() url = "/api/tts_get_url" data = {"message": "There is someone at the door."} req = await client.post(url, json=data) assert req.status == HTTPStatus.BAD_REQUEST async def test_tags_with_wave(hass: HomeAssistant, mock_provider: MockProvider) -> None: """Set up a TTS platform and call service and receive voice.""" # below data represents an empty wav file tts_data = bytes.fromhex( "52 49 46 46 24 00 00 00 57 41 56 45 66 6d 74 20 10 00 00 00 01 00 02 00" + "22 56 00 00 88 58 01 00 04 00 10 00 64 61 74 61 00 00 00 00" ) tagged_data = ORIG_WRITE_TAGS( "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.wav", tts_data, mock_provider, "AI person is in front of your door.", "en", None, ) assert tagged_data != tts_data @pytest.mark.parametrize( "value", ( "http://example.local:8123", "http://example.local", "http://example.local:80", "https://example.com", "https://example.com:443", "https://example.com:8123", ), ) def test_valid_base_url(value) -> None: """Test we validate base urls.""" assert _valid_base_url(value) == normalize_url(value) # Test we strip trailing `/` assert _valid_base_url(value + "/") == normalize_url(value) @pytest.mark.parametrize( "value", ( "http://example.local:8123/sub-path", "http://example.local/sub-path", "https://example.com/sub-path", "https://example.com:8123/sub-path", "mailto:some@email", "http:example.com", "http:/example.com", "http//example.com", "example.com", ), ) def test_invalid_base_url(value) -> None: """Test we catch bad base urls.""" with pytest.raises(vol.Invalid): _valid_base_url(value) @pytest.mark.parametrize( ("engine", "language", "options", "cache", "result_engine", "result_query"), ( (None, None, None, None, "test", ""), (None, "de", None, None, "test", "language=de"), (None, "de", {"voice": "henk"}, None, "test", "language=de&voice=henk"), (None, "de", None, True, "test", "cache=true&language=de"), ), ) async def test_generate_media_source_id( hass: HomeAssistant, setup_tts, engine, language, options, cache, result_engine, result_query, ) -> None: """Test generating a media source ID.""" media_source_id = tts.generate_media_source_id( hass, "msg", engine, language, options, cache ) assert media_source_id.startswith("media-source://tts/") _, _, engine_query = media_source_id.rpartition("/") engine, _, query = engine_query.partition("?") assert engine == result_engine assert query.startswith("message=msg") assert query[12:] == result_query @pytest.mark.parametrize( ("engine", "language", "options"), ( ("not-loaded-engine", None, None), (None, "unsupported-language", None), (None, None, {"option": "not-supported"}), ), ) async def test_generate_media_source_id_invalid_options( hass: HomeAssistant, setup_tts, engine, language, options ) -> None: """Test generating a media source ID.""" with pytest.raises(HomeAssistantError): tts.generate_media_source_id(hass, "msg", engine, language, options, None) def test_resolve_engine(hass: HomeAssistant, setup_tts) -> None: """Test resolving engine.""" assert tts.async_resolve_engine(hass, None) == "test" assert tts.async_resolve_engine(hass, "test") == "test" assert tts.async_resolve_engine(hass, "non-existing") is None with patch.dict(hass.data[tts.DOMAIN].providers, {}, clear=True): assert tts.async_resolve_engine(hass, "test") is None with patch.dict(hass.data[tts.DOMAIN].providers, {"cloud": object()}): assert tts.async_resolve_engine(hass, None) == "cloud" async def test_support_options(hass: HomeAssistant, setup_tts) -> None: """Test supporting options.""" # Language en is matched with en_US assert await tts.async_support_options(hass, "test", "en") is True assert await tts.async_support_options(hass, "test", "nl") is False assert ( await tts.async_support_options(hass, "test", "en", {"invalid_option": "yo"}) is False ) async def test_fetching_in_async( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test async fetching of data.""" tts_audio: asyncio.Future[bytes] = asyncio.Future() class ProviderWithAsyncFetching(MockProvider): """Provider that supports audio output option.""" @property def supported_options(self) -> list[str]: """Return list of supported options like voice, emotions.""" return [tts.ATTR_AUDIO_OUTPUT] @property def default_options(self) -> dict[str, str]: """Return a dict including the default options.""" return {tts.ATTR_AUDIO_OUTPUT: "mp3"} async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] | None = None ) -> tts.TtsAudioType: return ("mp3", await tts_audio) mock_integration(hass, MockModule(domain="test")) mock_platform(hass, "test.tts", MockTTS(ProviderWithAsyncFetching)) assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) # Test async_get_media_source_audio media_source_id = tts.generate_media_source_id( hass, "test message", "test", "en", None, None ) task = hass.async_create_task( tts.async_get_media_source_audio(hass, media_source_id) ) task2 = hass.async_create_task( tts.async_get_media_source_audio(hass, media_source_id) ) url = await get_media_source_url(hass, media_source_id) client = await hass_client() client_get_task = hass.async_create_task(client.get(url)) # Make sure that tasks are waiting for our future to resolve done, pending = await asyncio.wait((task, task2, client_get_task), timeout=0.1) assert len(done) == 0 assert len(pending) == 3 tts_audio.set_result(b"test") assert await task == ("mp3", b"test") assert await task2 == ("mp3", b"test") req = await client_get_task assert req.status == HTTPStatus.OK assert await req.read() == b"test" # Test error is not cached media_source_id = tts.generate_media_source_id( hass, "test message 2", "test", "en", None, None ) tts_audio = asyncio.Future() tts_audio.set_exception(HomeAssistantError("test error")) with pytest.raises(HomeAssistantError): assert await tts.async_get_media_source_audio(hass, media_source_id) tts_audio = asyncio.Future() tts_audio.set_result(b"test 2") assert await tts.async_get_media_source_audio(hass, media_source_id) == ( "mp3", b"test 2", ) async def test_ws_list_engines( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_tts ) -> None: """Test streaming audio and getting response.""" client = await hass_ws_client() await client.send_json_auto_id({"type": "tts/engine/list"}) msg = await client.receive_json() assert msg["success"] assert msg["result"] == { "providers": [ { "engine_id": "test", "supported_languages": ["de_CH", "de_DE", "en_GB", "en_US"], } ] } await client.send_json_auto_id({"type": "tts/engine/list", "language": "smurfish"}) msg = await client.receive_json() assert msg["success"] assert msg["result"] == { "providers": [{"engine_id": "test", "supported_languages": []}] } await client.send_json_auto_id({"type": "tts/engine/list", "language": "en"}) msg = await client.receive_json() assert msg["success"] assert msg["result"] == { "providers": [{"engine_id": "test", "supported_languages": ["en_US", "en_GB"]}] } await client.send_json_auto_id({"type": "tts/engine/list", "language": "en-UK"}) msg = await client.receive_json() assert msg["success"] assert msg["result"] == { "providers": [{"engine_id": "test", "supported_languages": ["en_GB", "en_US"]}] } await client.send_json_auto_id({"type": "tts/engine/list", "language": "de"}) msg = await client.receive_json() assert msg["type"] == "result" assert msg["success"] assert msg["result"] == { "providers": [{"engine_id": "test", "supported_languages": ["de_DE", "de_CH"]}] } await client.send_json_auto_id( {"type": "tts/engine/list", "language": "de", "country": "ch"} ) msg = await client.receive_json() assert msg["type"] == "result" assert msg["success"] assert msg["result"] == { "providers": [{"engine_id": "test", "supported_languages": ["de_CH", "de_DE"]}] } async def test_ws_list_voices( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_tts ) -> None: """Test streaming audio and getting response.""" client = await hass_ws_client() await client.send_json_auto_id( { "type": "tts/engine/voices", "engine_id": "smurf_tts", "language": "smurfish", } ) msg = await client.receive_json() assert not msg["success"] assert msg["error"] == { "code": "not_found", "message": "tts engine smurf_tts not found", } await client.send_json_auto_id( { "type": "tts/engine/voices", "engine_id": "test", "language": "smurfish", } ) msg = await client.receive_json() assert msg["success"] assert msg["result"] == {"voices": None} await client.send_json_auto_id( { "type": "tts/engine/voices", "engine_id": "test", "language": "en-US", } ) msg = await client.receive_json() assert msg["success"] assert msg["result"] == {"voices": ["James Earl Jones", "Fran Drescher"]}