From 2dec38d8d4f2461448fc05de352e619bc09167dd Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 13 Dec 2016 08:23:08 +0100 Subject: [PATCH] TTS Component / Google speech platform (#4837) * TTS Component / Google speech platform * Change file backend handling / cache * Use mimetype / rename Provider function / allow cache on service call * Add a memcache for faster response * Add demo platform * First version of unittest * Address comments * improve error handling / address comments * Add google unittest & check http response code * Change url param handling * add test for other language * Change hash to sha256 for same hash on every os/hardware * add unittest for receive demo data * add test for error cases * Test case load from file to mem over aiohttp server * Use cache SpeechManager level, address other comments * Add service for clear cache * Update service.yaml * add support for spliting google message --- .../components/media_player/__init__.py | 2 +- homeassistant/components/tts/__init__.py | 421 ++++++++++++++++++ homeassistant/components/tts/demo.mp3 | Bin 0 -> 8256 bytes homeassistant/components/tts/demo.py | 29 ++ homeassistant/components/tts/google.py | 117 +++++ homeassistant/components/tts/services.yaml | 14 + requirements_all.txt | 3 + tests/components/tts/__init__.py | 1 + tests/components/tts/test_google.py | 199 +++++++++ tests/components/tts/test_init.py | 320 +++++++++++++ tests/test_util/aiohttp.py | 9 +- 11 files changed, 1110 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/tts/__init__.py create mode 100644 homeassistant/components/tts/demo.mp3 create mode 100644 homeassistant/components/tts/demo.py create mode 100644 homeassistant/components/tts/google.py create mode 100644 homeassistant/components/tts/services.yaml create mode 100644 tests/components/tts/__init__.py create mode 100644 tests/components/tts/test_google.py create mode 100644 tests/components/tts/test_init.py diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index fa2ecee4337..3dea75df874 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -162,7 +162,7 @@ MEDIA_PLAYER_MEDIA_SEEK_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string, vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string, - ATTR_MEDIA_ENQUEUE: cv.boolean, + vol.Optional(ATTR_MEDIA_ENQUEUE): cv.boolean, }) MEDIA_PLAYER_SELECT_SOURCE_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py new file mode 100644 index 00000000000..0e75de88cc5 --- /dev/null +++ b/homeassistant/components/tts/__init__.py @@ -0,0 +1,421 @@ +""" +Provide functionality to TTS. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tts/ +""" +import asyncio +import logging +import hashlib +import mimetypes +import os +import re + +from aiohttp import web +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.bootstrap import async_prepare_setup_platform +from homeassistant.core import callback +from homeassistant.config import load_yaml_config_file +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.media_player import ( + SERVICE_PLAY_MEDIA, MEDIA_TYPE_MUSIC, ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_per_platform +import homeassistant.helpers.config_validation as cv + +DOMAIN = 'tts' +DEPENDENCIES = ['http'] + +_LOGGER = logging.getLogger(__name__) + +MEM_CACHE_FILENAME = 'filename' +MEM_CACHE_VOICE = 'voice' + +CONF_LANG = 'language' +CONF_CACHE = 'cache' +CONF_CACHE_DIR = 'cache_dir' +CONF_TIME_MEMORY = 'time_memory' + +DEFAULT_CACHE = True +DEFAULT_CACHE_DIR = "tts" +DEFAULT_LANG = 'en' +DEFAULT_TIME_MEMORY = 300 + +SERVICE_SAY = 'say' +SERVICE_CLEAR_CACHE = 'clear_cache' + +ATTR_MESSAGE = 'message' +ATTR_CACHE = 'cache' + +_RE_VOICE_FILE = re.compile(r"([a-f0-9]{40})_([a-z]+)\.[a-z0-9]{3,4}") + +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_LANG, default=DEFAULT_LANG): cv.string, + vol.Optional(CONF_CACHE, default=DEFAULT_CACHE): cv.boolean, + vol.Optional(CONF_CACHE_DIR, default=DEFAULT_CACHE_DIR): cv.string, + vol.Optional(CONF_TIME_MEMORY, default=DEFAULT_TIME_MEMORY): + vol.All(vol.Coerce(int), vol.Range(min=60, max=57600)), +}) + + +SCHEMA_SERVICE_SAY = vol.Schema({ + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_CACHE): cv.boolean, +}) + +SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) + + +@asyncio.coroutine +def async_setup(hass, config): + """Setup TTS.""" + tts = SpeechManager(hass) + + try: + conf = config[DOMAIN][0] if len(config.get(DOMAIN, [])) > 0 else {} + use_cache = conf.get(CONF_CACHE, DEFAULT_CACHE) + cache_dir = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) + time_memory = conf.get(CONF_TIME_MEMORY, DEFAULT_LANG) + + yield from tts.async_init_cache(use_cache, cache_dir, time_memory) + except (HomeAssistantError, KeyError) as err: + _LOGGER.error("Error on cache init %s", err) + return False + + hass.http.register_view(TextToSpeechView(tts)) + + descriptions = yield from hass.loop.run_in_executor( + None, load_yaml_config_file, + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + @asyncio.coroutine + def async_setup_platform(p_type, p_config, disc_info=None): + """Setup a tts platform.""" + platform = yield from async_prepare_setup_platform( + hass, config, DOMAIN, p_type) + if platform is None: + return + + try: + if hasattr(platform, 'async_get_engine'): + provider = yield from platform.async_get_engine( + hass, p_config) + else: + provider = yield from hass.loop.run_in_executor( + None, platform.get_engine, hass, p_config) + + if provider is None: + _LOGGER.error('Error setting up platform %s', p_type) + return + + tts.async_register_engine(p_type, provider, p_config) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error setting up platform %s', p_type) + return + + @asyncio.coroutine + def async_say_handle(service): + """Service handle for say.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + message = service.data.get(ATTR_MESSAGE) + cache = service.data.get(ATTR_CACHE) + + try: + url = yield from tts.async_get_url( + p_type, message, cache=cache) + except HomeAssistantError as err: + _LOGGER.error("Error on init tts: %s", err) + return + + data = { + ATTR_MEDIA_CONTENT_ID: url, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + } + + if entity_ids: + data[ATTR_ENTITY_ID] = entity_ids + + yield from hass.services.async_call( + DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True) + + hass.services.async_register( + DOMAIN, "{}_{}".format(p_type, SERVICE_SAY), async_say_handle, + descriptions.get(SERVICE_SAY), schema=SCHEMA_SERVICE_SAY) + + setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config + in config_per_platform(config, DOMAIN)] + + if setup_tasks: + yield from asyncio.wait(setup_tasks, loop=hass.loop) + + @asyncio.coroutine + def async_clear_cache_handle(service): + """Handle clear cache service call.""" + yield from tts.async_clear_cache() + + hass.services.async_register( + DOMAIN, SERVICE_CLEAR_CACHE, async_clear_cache_handle, + descriptions.get(SERVICE_CLEAR_CACHE), schema=SERVICE_CLEAR_CACHE) + + return True + + +class SpeechManager(object): + """Representation of a speech store.""" + + def __init__(self, hass): + """Initialize a speech store.""" + self.hass = hass + self.providers = {} + + self.use_cache = True + self.cache_dir = None + self.time_memory = None + self.file_cache = {} + self.mem_cache = {} + + @asyncio.coroutine + def async_init_cache(self, use_cache, cache_dir, time_memory): + """Init config folder and load file cache.""" + self.use_cache = use_cache + self.time_memory = time_memory + + def init_tts_cache_dir(cache_dir): + """Init cache folder.""" + if not os.path.isabs(cache_dir): + cache_dir = self.hass.config.path(cache_dir) + if not os.path.isdir(cache_dir): + _LOGGER.info("Create cache dir %s.", cache_dir) + os.mkdir(cache_dir) + return cache_dir + + try: + self.cache_dir = yield from self.hass.loop.run_in_executor( + None, init_tts_cache_dir, cache_dir) + except OSError as err: + raise HomeAssistantError( + "Can't init cache dir {}".format(err)) + + def get_cache_files(): + """Return a dict of given engine files.""" + cache = {} + + folder_data = os.listdir(self.cache_dir) + for file_data in folder_data: + record = _RE_VOICE_FILE.match(file_data) + if record: + key = "{}_{}".format(record.group(1), record.group(2)) + cache[key.lower()] = file_data.lower() + return cache + + try: + cache_files = yield from self.hass.loop.run_in_executor( + None, get_cache_files) + except OSError as err: + raise HomeAssistantError( + "Can't read cache dir {}".format(err)) + + if cache_files: + self.file_cache.update(cache_files) + + @asyncio.coroutine + def async_clear_cache(self): + """Read file cache and delete files.""" + self.mem_cache = {} + + def remove_files(): + """Remove files from filesystem.""" + for _, filename in self.file_cache.items(): + try: + os.remove(os.path.join(self.cache_dir), filename) + except OSError: + pass + + yield from self.hass.loop.run_in_executor(None, remove_files) + self.file_cache = {} + + @callback + def async_register_engine(self, engine, provider, config): + """Register a TTS provider.""" + provider.hass = self.hass + provider.language = config.get(CONF_LANG) + self.providers[engine] = provider + + @asyncio.coroutine + def async_get_url(self, engine, message, cache=None): + """Get URL for play message. + + This method is a coroutine. + """ + msg_hash = hashlib.sha1(bytes(message, 'utf-8')).hexdigest() + key = ("{}_{}".format(msg_hash, engine)).lower() + use_cache = cache if cache is not None else self.use_cache + + # is speech allready in memory + if key in self.mem_cache: + filename = self.mem_cache[key][MEM_CACHE_FILENAME] + # is file store in file cache + elif use_cache and key in self.file_cache: + filename = self.file_cache[key] + self.hass.async_add_job(self.async_file_to_mem(engine, key)) + # load speech from provider into memory + else: + filename = yield from self.async_get_tts_audio( + engine, key, message, use_cache) + + return "{}/api/tts_proxy/{}".format( + self.hass.config.api.base_url, filename) + + @asyncio.coroutine + def async_get_tts_audio(self, engine, key, message, cache): + """Receive TTS and store for view in cache. + + This method is a coroutine. + """ + provider = self.providers[engine] + extension, data = yield from provider.async_get_tts_audio(message) + + if data is None or extension is None: + raise HomeAssistantError( + "No TTS from {} for '{}'".format(engine, message)) + + # create file infos + filename = ("{}.{}".format(key, extension)).lower() + + # save to memory + self._async_store_to_memcache(key, filename, data) + + if cache: + self.hass.async_add_job( + self.async_save_tts_audio(key, filename, data)) + + return filename + + @asyncio.coroutine + def async_save_tts_audio(self, key, filename, data): + """Store voice data to file and file_cache. + + This method is a coroutine. + """ + voice_file = os.path.join(self.cache_dir, filename) + + def save_speech(): + """Store speech to filesystem.""" + with open(voice_file, 'wb') as speech: + speech.write(data) + + try: + yield from self.hass.loop.run_in_executor(None, save_speech) + self.file_cache[key] = filename + except OSError: + _LOGGER.error("Can't write %s", filename) + + @asyncio.coroutine + def async_file_to_mem(self, engine, key): + """Load voice from file cache into memory. + + This method is a coroutine. + """ + filename = self.file_cache.get(key) + if not filename: + raise HomeAssistantError("Key {} not in file cache!".format(key)) + + voice_file = os.path.join(self.cache_dir, filename) + + def load_speech(): + """Load a speech from filesystem.""" + with open(voice_file, 'rb') as speech: + return speech.read() + + try: + data = yield from self.hass.loop.run_in_executor(None, load_speech) + except OSError: + raise HomeAssistantError("Can't read {}".format(voice_file)) + + self._async_store_to_memcache(key, filename, data) + + @callback + def _async_store_to_memcache(self, key, filename, data): + """Store data to memcache and set timer to remove it.""" + self.mem_cache[key] = { + MEM_CACHE_FILENAME: filename, + MEM_CACHE_VOICE: data, + } + + @callback + def async_remove_from_mem(): + """Cleanup memcache.""" + self.mem_cache.pop(key) + + self.hass.loop.call_later(self.time_memory, async_remove_from_mem) + + @asyncio.coroutine + def async_read_tts(self, filename): + """Read a voice file and return binary. + + This method is a coroutine. + """ + record = _RE_VOICE_FILE.match(filename.lower()) + if not record: + raise HomeAssistantError("Wrong tts file format!") + + key = "{}_{}".format(record.group(1), record.group(2)) + + if key not in self.mem_cache: + if key not in self.file_cache: + raise HomeAssistantError("%s not in cache!", key) + engine = record.group(2) + yield from self.async_file_to_mem(engine, key) + + content, _ = mimetypes.guess_type(filename) + return (content, self.mem_cache[key][MEM_CACHE_VOICE]) + + +class Provider(object): + """Represent a single provider.""" + + hass = None + language = DEFAULT_LANG + + def get_tts_audio(self, message): + """Load tts audio file from provider.""" + raise NotImplementedError() + + @asyncio.coroutine + def async_get_tts_audio(self, message): + """Load tts audio file from provider. + + Return a tuple of file extension and data as bytes. + + This method is a coroutine. + """ + extension, data = yield from self.hass.loop.run_in_executor( + None, self.get_tts_audio, message) + return (extension, data) + + +class TextToSpeechView(HomeAssistantView): + """TTS view to serve an speech audio.""" + + requires_auth = False + url = "/api/tts_proxy/{filename}" + name = "api:tts:speech" + + def __init__(self, tts): + """Initialize a tts view.""" + self.tts = tts + + @asyncio.coroutine + def get(self, request, filename): + """Start a get request.""" + try: + content, data = yield from self.tts.async_read_tts(filename) + except HomeAssistantError as err: + _LOGGER.error("Error on load tts: %s", err) + return web.Response(status=404) + + return web.Response(body=data, content_type=content) diff --git a/homeassistant/components/tts/demo.mp3 b/homeassistant/components/tts/demo.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f34241c769856718331a5e523f7d9b8b026a2c30 GIT binary patch literal 8256 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 MESSAGE_SIZE: + idx = fullstring.rfind(' ', 0, MESSAGE_SIZE) + return [fullstring[:idx]] + split_by_space(fullstring[idx:]) + else: + return [fullstring] + + msg_parts = [] + for part in parts: + msg_parts += split_by_space(part) + + return [msg for msg in msg_parts if len(msg) > 0] diff --git a/homeassistant/components/tts/services.yaml b/homeassistant/components/tts/services.yaml new file mode 100644 index 00000000000..aba1334da87 --- /dev/null +++ b/homeassistant/components/tts/services.yaml @@ -0,0 +1,14 @@ +say: + description: Say some things on a media player. + + fields: + entity_id: + description: Name(s) of media player entities + example: 'media_player.floor' + + message: + description: Text to speak on devices + example: 'My name is hanna' + +clear_cache: + description: Remove cache files and RAM cache. diff --git a/requirements_all.txt b/requirements_all.txt index 799b2b29b11..c85e3285e7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -130,6 +130,9 @@ freesms==0.1.1 # homeassistant.components.conversation fuzzywuzzy==0.14.0 +# homeassistant.components.tts.google +gTTS-token==1.1.1 + # homeassistant.components.device_tracker.bluetooth_le_tracker # gattlib==0.20150805 diff --git a/tests/components/tts/__init__.py b/tests/components/tts/__init__.py new file mode 100644 index 00000000000..f5eb0731409 --- /dev/null +++ b/tests/components/tts/__init__.py @@ -0,0 +1 @@ +"""The tests for tts platforms.""" diff --git a/tests/components/tts/test_google.py b/tests/components/tts/test_google.py new file mode 100644 index 00000000000..623a96f1dfb --- /dev/null +++ b/tests/components/tts/test_google.py @@ -0,0 +1,199 @@ +"""The tests for the Google speech platform.""" +import asyncio +import os +import shutil +from unittest.mock import patch + +import homeassistant.components.tts as tts +from homeassistant.components.media_player import ( + SERVICE_PLAY_MEDIA, ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP) +from homeassistant.bootstrap import setup_component + +from tests.common import ( + get_test_home_assistant, assert_setup_component, mock_service) + + +class TestTTSGooglePlatform(object): + """Test the Google speech component.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + self.url = "http://translate.google.com/translate_tts" + self.url_param = { + 'tl': 'en', + 'q': 'I%20person%20is%20on%20front%20of%20your%20door.', + 'tk': 5, + 'client': 'tw-ob', + 'textlen': 34, + 'total': 1, + 'idx': 0, + 'ie': 'UTF-8', + } + + def teardown_method(self): + """Stop everything that was started.""" + default_tts = self.hass.config.path(tts.DEFAULT_CACHE_DIR) + if os.path.isdir(default_tts): + shutil.rmtree(default_tts) + + self.hass.stop() + + def test_setup_component(self): + """Test setup component.""" + config = { + tts.DOMAIN: { + 'platform': 'google', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + @patch('gtts_token.gtts_token.Token.calculate_token', autospec=True, + return_value=5) + def test_service_say(self, mock_calculate, aioclient_mock): + """Test service call say.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + aioclient_mock.get( + self.url, params=self.url_param, status=200, content=b'test') + + config = { + tts.DOMAIN: { + 'platform': 'google', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'google_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + }) + self.hass.block_till_done() + + assert len(calls) == 1 + assert len(aioclient_mock.mock_calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".mp3") != -1 + + @patch('gtts_token.gtts_token.Token.calculate_token', autospec=True, + return_value=5) + def test_service_say_german(self, mock_calculate, aioclient_mock): + """Test service call say with german code.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + self.url_param['tl'] = 'de' + aioclient_mock.get( + self.url, params=self.url_param, status=200, content=b'test') + + config = { + tts.DOMAIN: { + 'platform': 'google', + 'language': 'de', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'google_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + }) + self.hass.block_till_done() + + assert len(calls) == 1 + assert len(aioclient_mock.mock_calls) == 1 + + @patch('gtts_token.gtts_token.Token.calculate_token', autospec=True, + return_value=5) + def test_service_say_error(self, mock_calculate, aioclient_mock): + """Test service call say with http response 400.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + aioclient_mock.get( + self.url, params=self.url_param, status=400, content=b'test') + + config = { + tts.DOMAIN: { + 'platform': 'google', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'google_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + }) + self.hass.block_till_done() + + assert len(calls) == 0 + assert len(aioclient_mock.mock_calls) == 1 + + @patch('gtts_token.gtts_token.Token.calculate_token', autospec=True, + return_value=5) + def test_service_say_timeout(self, mock_calculate, aioclient_mock): + """Test service call say with http timeout.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + aioclient_mock.get( + self.url, params=self.url_param, exc=asyncio.TimeoutError()) + + config = { + tts.DOMAIN: { + 'platform': 'google', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'google_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + }) + self.hass.block_till_done() + + assert len(calls) == 0 + assert len(aioclient_mock.mock_calls) == 1 + + @patch('gtts_token.gtts_token.Token.calculate_token', autospec=True, + return_value=5) + def test_service_say_long_size(self, mock_calculate, aioclient_mock): + """Test service call say with a lot of text.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + self.url_param['total'] = 9 + self.url_param['q'] = "I%20person%20is%20on%20front%20of%20your%20door" + self.url_param['textlen'] = 33 + for idx in range(0, 9): + self.url_param['idx'] = idx + aioclient_mock.get( + self.url, params=self.url_param, status=200, content=b'test') + + config = { + tts.DOMAIN: { + 'platform': 'google', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'google_say', { + tts.ATTR_MESSAGE: ("I person is on front of your door." + "I person is on front of your door." + "I person is on front of your door." + "I person is on front of your door." + "I person is on front of your door." + "I person is on front of your door." + "I person is on front of your door." + "I person is on front of your door." + "I person is on front of your door."), + }) + self.hass.block_till_done() + + assert len(calls) == 1 + assert len(aioclient_mock.mock_calls) == 9 + assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".mp3") != -1 diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py new file mode 100644 index 00000000000..fbdbddb8db5 --- /dev/null +++ b/tests/components/tts/test_init.py @@ -0,0 +1,320 @@ +"""The tests for the TTS component.""" +import os +import shutil +from unittest.mock import patch + +import requests + +import homeassistant.components.tts as tts +from homeassistant.components.tts.demo import DemoProvider +from homeassistant.components.media_player import ( + SERVICE_PLAY_MEDIA, MEDIA_TYPE_MUSIC, ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP) +from homeassistant.bootstrap import setup_component + +from tests.common import ( + get_test_home_assistant, assert_setup_component, mock_service) + + +class TestTTS(object): + """Test the Google speech component.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.demo_provider = DemoProvider() + self.default_tts_cache = self.hass.config.path(tts.DEFAULT_CACHE_DIR) + + def teardown_method(self): + """Stop everything that was started.""" + if os.path.isdir(self.default_tts_cache): + shutil.rmtree(self.default_tts_cache) + + self.hass.stop() + + def test_setup_component_demo(self): + """Setup the demo platform with defaults.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + assert self.hass.services.has_service(tts.DOMAIN, 'demo_say') + assert self.hass.services.has_service(tts.DOMAIN, 'clear_cache') + + @patch('os.mkdir', side_effect=OSError(2, "No access")) + def test_setup_component_demo_no_access_cache_folder(self, mock_mkdir): + """Setup the demo platform with defaults.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + assert not setup_component(self.hass, tts.DOMAIN, config) + + assert not self.hass.services.has_service(tts.DOMAIN, 'demo_say') + assert not self.hass.services.has_service(tts.DOMAIN, 'clear_cache') + + def test_setup_component_and_test_service(self): + """Setup the demo platform and call service.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'demo_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + }) + self.hass.block_till_done() + + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC + assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find( + "/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" + "_demo.mp3") \ + != -1 + assert os.path.isfile(os.path.join( + self.default_tts_cache, + "265944c108cbb00b2a621be5930513e03a0bb2cd_demo.mp3")) + + def test_setup_component_and_test_service_clear_cache(self): + """Setup the demo platform and call service clear cache.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'demo_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + }) + self.hass.block_till_done() + + assert len(calls) == 1 + assert os.path.isfile(os.path.join( + self.default_tts_cache, + "265944c108cbb00b2a621be5930513e03a0bb2cd_demo.mp3")) + + self.hass.services.call(tts.DOMAIN, tts.SERVICE_CLEAR_CACHE, {}) + self.hass.block_till_done() + + assert not os.path.isfile(os.path.join( + self.default_tts_cache, + "265944c108cbb00b2a621be5930513e03a0bb2cd_demo.mp3")) + + def test_setup_component_and_test_service_with_receive_voice(self): + """Setup the demo platform and call service and receive voice.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.start() + + self.hass.services.call(tts.DOMAIN, 'demo_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + }) + self.hass.block_till_done() + + assert len(calls) == 1 + req = requests.get(calls[0].data[ATTR_MEDIA_CONTENT_ID]) + _, demo_data = self.demo_provider.get_tts_audio("bla") + assert req.status_code == 200 + assert req.content == demo_data + + def test_setup_component_and_web_view_wrong_file(self): + """Setup the demo platform and receive wrong file from web.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.start() + + url = ("{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" + "_demo.mp3").format(self.hass.config.api.base_url) + + req = requests.get(url) + assert req.status_code == 404 + + def test_setup_component_and_web_view_wrong_filename(self): + """Setup the demo platform and receive wrong filename from web.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.start() + + url = ("{}/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd" + "_demo.mp3").format(self.hass.config.api.base_url) + + req = requests.get(url) + assert req.status_code == 404 + + def test_setup_component_test_without_cache(self): + """Setup demo platform without cache.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = { + tts.DOMAIN: { + 'platform': 'demo', + 'cache': False, + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'demo_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + }) + self.hass.block_till_done() + + assert len(calls) == 1 + assert not os.path.isfile(os.path.join( + self.default_tts_cache, + "265944c108cbb00b2a621be5930513e03a0bb2cd_demo.mp3")) + + def test_setup_component_test_with_cache_call_service_without_cache(self): + """Setup demo platform with cache and call service without cache.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = { + tts.DOMAIN: { + 'platform': 'demo', + 'cache': True, + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'demo_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + tts.ATTR_CACHE: False, + }) + self.hass.block_till_done() + + assert len(calls) == 1 + assert not os.path.isfile(os.path.join( + self.default_tts_cache, + "265944c108cbb00b2a621be5930513e03a0bb2cd_demo.mp3")) + + def test_setup_component_test_with_cache_dir(self): + """Setup demo platform with cache and call service without cache.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + _, demo_data = self.demo_provider.get_tts_audio("bla") + cache_file = os.path.join( + self.default_tts_cache, + "265944c108cbb00b2a621be5930513e03a0bb2cd_demo.mp3") + + os.mkdir(self.default_tts_cache) + with open(cache_file, "wb") as voice_file: + voice_file.write(demo_data) + + config = { + tts.DOMAIN: { + 'platform': 'demo', + 'cache': True, + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + with patch('homeassistant.components.tts.demo.DemoProvider.' + 'get_tts_audio', return_value=None): + self.hass.services.call(tts.DOMAIN, 'demo_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + }) + self.hass.block_till_done() + + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find( + "/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" + "_demo.mp3") \ + != -1 + + @patch('homeassistant.components.tts.demo.DemoProvider.get_tts_audio', + return_value=None) + def test_setup_component_test_with_error_on_get_tts(self, tts_mock): + """Setup demo platform with wrong get_tts_audio.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = { + tts.DOMAIN: { + 'platform': 'demo' + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'demo_say', { + tts.ATTR_MESSAGE: "I person is on front of your door.", + }) + self.hass.block_till_done() + + assert len(calls) == 0 + + def test_setup_component_load_cache_retrieve_without_mem_cache(self): + """Setup component and load cache and get without mem cache.""" + _, demo_data = self.demo_provider.get_tts_audio("bla") + cache_file = os.path.join( + self.default_tts_cache, + "265944c108cbb00b2a621be5930513e03a0bb2cd_demo.mp3") + + os.mkdir(self.default_tts_cache) + with open(cache_file, "wb") as voice_file: + voice_file.write(demo_data) + + config = { + tts.DOMAIN: { + 'platform': 'demo', + 'cache': True, + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.start() + + url = ("{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" + "_demo.mp3").format(self.hass.config.api.base_url) + + req = requests.get(url) + assert req.status_code == 200 + assert req.content == demo_data diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index d6f0c80b435..4abf43a6e42 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -23,6 +23,7 @@ class AiohttpClientMocker: content=None, json=None, params=None, + headers=None, exc=None): """Mock a request.""" if json: @@ -65,8 +66,8 @@ class AiohttpClientMocker: return len(self.mock_calls) @asyncio.coroutine - def match_request(self, method, url, *, auth=None, params=None): \ - # pylint: disable=unused-variable + def match_request(self, method, url, *, auth=None, params=None, + headers=None): # pylint: disable=unused-variable """Match a request against pre-registered requests.""" for response in self._mocks: if response.match_request(method, url, params): @@ -76,8 +77,8 @@ class AiohttpClientMocker: raise self.exc return response - assert False, "No mock registered for {} {}".format(method.upper(), - url) + assert False, "No mock registered for {} {} {}".format(method.upper(), + url, params) class AiohttpClientMockResponse: