diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 62611b82496f..064428c010cb 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant import core from homeassistant.loader import bind_hass from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, HTTP_BAD_REQUEST) + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON) from homeassistant.helpers import intent, config_validation as cv from homeassistant.components import http @@ -39,6 +39,10 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({ }) })}, extra=vol.ALLOW_EXTRA) +INTENT_TURN_ON = 'HassTurnOn' +INTENT_TURN_OFF = 'HassTurnOff' +REGEX_TYPE = type(re.compile('')) + _LOGGER = logging.getLogger(__name__) @@ -60,7 +64,11 @@ def async_register(hass, intent_type, utterances): if conf is None: conf = intents[intent_type] = [] - conf.extend(_create_matcher(utterance) for utterance in utterances) + for utterance in utterances: + if isinstance(utterance, REGEX_TYPE): + conf.append(utterance) + else: + conf.append(_create_matcher(utterance)) @asyncio.coroutine @@ -93,6 +101,13 @@ def async_setup(hass, config): hass.http.register_view(ConversationProcessView) + hass.helpers.intent.async_register(TurnOnIntent()) + hass.helpers.intent.async_register(TurnOffIntent()) + async_register(hass, INTENT_TURN_ON, + ['Turn {name} on', 'Turn on {name}']) + async_register(hass, INTENT_TURN_OFF, [ + 'Turn {name} off', 'Turn off {name}']) + return True @@ -128,48 +143,84 @@ def _process(hass, text): if not match: continue - response = yield from intent.async_handle( - hass, DOMAIN, intent_type, + response = yield from hass.helpers.intent.async_handle( + DOMAIN, intent_type, {key: {'value': value} for key, value in match.groupdict().items()}, text) return response + +@core.callback +def _match_entity(hass, name): + """Match a name to an entity.""" from fuzzywuzzy import process as fuzzyExtract - text = text.lower() - match = REGEX_TURN_COMMAND.match(text) - - if not match: - _LOGGER.error("Unable to process: %s", text) - return None - - name, command = match.groups() entities = {state.entity_id: state.name for state in hass.states.async_all()} - entity_ids = fuzzyExtract.extractOne( + entity_id = fuzzyExtract.extractOne( name, entities, score_cutoff=65)[2] + return hass.states.get(entity_id) if entity_id else None - if not entity_ids: - _LOGGER.error( - "Could not find entity id %s from text %s", name, text) - return None - if command == 'on': +class TurnOnIntent(intent.IntentHandler): + """Handle turning item on intents.""" + + intent_type = INTENT_TURN_ON + slot_schema = { + 'name': cv.string, + } + + @asyncio.coroutine + def async_handle(self, intent_obj): + """Handle turn on intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + name = slots['name']['value'] + entity = _match_entity(hass, name) + + if not entity: + _LOGGER.error("Could not find entity id for %s", name) + return None + yield from hass.services.async_call( core.DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity_ids, + ATTR_ENTITY_ID: entity.entity_id, }, blocking=True) - elif command == 'off': + response = intent_obj.create_response() + response.async_set_speech( + 'Turned on {}'.format(entity.name)) + return response + + +class TurnOffIntent(intent.IntentHandler): + """Handle turning item off intents.""" + + intent_type = INTENT_TURN_OFF + slot_schema = { + 'name': cv.string, + } + + @asyncio.coroutine + def async_handle(self, intent_obj): + """Handle turn off intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + name = slots['name']['value'] + entity = _match_entity(hass, name) + + if not entity: + _LOGGER.error("Could not find entity id for %s", name) + return None + yield from hass.services.async_call( core.DOMAIN, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: entity_ids, + ATTR_ENTITY_ID: entity.entity_id, }, blocking=True) - else: - _LOGGER.error('Got unsupported command %s from text %s', - command, text) - - return None + response = intent_obj.create_response() + response.async_set_speech( + 'Turned off {}'.format(entity.name)) + return response class ConversationProcessView(http.HomeAssistantView): @@ -178,23 +229,15 @@ class ConversationProcessView(http.HomeAssistantView): url = '/api/conversation/process' name = "api:conversation:process" + @http.RequestDataValidator(vol.Schema({ + vol.Required('text'): str, + })) @asyncio.coroutine - def post(self, request): + def post(self, request, data): """Send a request for processing.""" hass = request.app['hass'] - try: - data = yield from request.json() - except ValueError: - return self.json_message('Invalid JSON specified', - HTTP_BAD_REQUEST) - text = data.get('text') - - if text is None: - return self.json_message('Missing "text" key in JSON.', - HTTP_BAD_REQUEST) - - intent_result = yield from _process(hass, text) + intent_result = yield from _process(hass, data['text']) if intent_result is None: intent_result = intent.IntentResponse() diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index 138ae1668f8f..fab1e24d8e7d 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -1,123 +1,14 @@ """The tests for the Conversation component.""" # pylint: disable=protected-access import asyncio -import unittest -from unittest.mock import patch -from homeassistant.core import callback -from homeassistant.setup import setup_component, async_setup_component -import homeassistant.components as core_components +import pytest + +from homeassistant.setup import async_setup_component from homeassistant.components import conversation -from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.util.async import run_coroutine_threadsafe from homeassistant.helpers import intent -from tests.common import get_test_home_assistant, async_mock_intent - - -class TestConversation(unittest.TestCase): - """Test the conversation component.""" - - # pylint: disable=invalid-name - def setUp(self): - """Setup things to be run when tests are started.""" - self.ent_id = 'light.kitchen_lights' - self.hass = get_test_home_assistant() - self.hass.states.set(self.ent_id, 'on') - self.assertTrue(run_coroutine_threadsafe( - core_components.async_setup(self.hass, {}), self.hass.loop - ).result()) - self.assertTrue(setup_component(self.hass, conversation.DOMAIN, { - conversation.DOMAIN: {} - })) - - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_turn_on(self): - """Setup and perform good turn on requests.""" - calls = [] - - @callback - def record_call(service): - """Recorder for a call.""" - calls.append(service) - - self.hass.services.register('light', 'turn_on', record_call) - - event_data = {conversation.ATTR_TEXT: 'turn kitchen lights on'} - self.assertTrue(self.hass.services.call( - conversation.DOMAIN, 'process', event_data, True)) - - call = calls[-1] - self.assertEqual('light', call.domain) - self.assertEqual('turn_on', call.service) - self.assertEqual([self.ent_id], call.data[ATTR_ENTITY_ID]) - - def test_turn_off(self): - """Setup and perform good turn off requests.""" - calls = [] - - @callback - def record_call(service): - """Recorder for a call.""" - calls.append(service) - - self.hass.services.register('light', 'turn_off', record_call) - - event_data = {conversation.ATTR_TEXT: 'turn kitchen lights off'} - self.assertTrue(self.hass.services.call( - conversation.DOMAIN, 'process', event_data, True)) - - call = calls[-1] - self.assertEqual('light', call.domain) - self.assertEqual('turn_off', call.service) - self.assertEqual([self.ent_id], call.data[ATTR_ENTITY_ID]) - - @patch('homeassistant.components.conversation.logging.Logger.error') - @patch('homeassistant.core.ServiceRegistry.call') - def test_bad_request_format(self, mock_logger, mock_call): - """Setup and perform a badly formatted request.""" - event_data = { - conversation.ATTR_TEXT: - 'what is the answer to the ultimate question of life, ' + - 'the universe and everything'} - self.assertTrue(self.hass.services.call( - conversation.DOMAIN, 'process', event_data, True)) - self.assertTrue(mock_logger.called) - self.assertFalse(mock_call.called) - - @patch('homeassistant.components.conversation.logging.Logger.error') - @patch('homeassistant.core.ServiceRegistry.call') - def test_bad_request_entity(self, mock_logger, mock_call): - """Setup and perform requests with bad entity id.""" - event_data = {conversation.ATTR_TEXT: 'turn something off'} - self.assertTrue(self.hass.services.call( - conversation.DOMAIN, 'process', event_data, True)) - self.assertTrue(mock_logger.called) - self.assertFalse(mock_call.called) - - @patch('homeassistant.components.conversation.logging.Logger.error') - @patch('homeassistant.core.ServiceRegistry.call') - def test_bad_request_command(self, mock_logger, mock_call): - """Setup and perform requests with bad command.""" - event_data = {conversation.ATTR_TEXT: 'turn kitchen lights over'} - self.assertTrue(self.hass.services.call( - conversation.DOMAIN, 'process', event_data, True)) - self.assertTrue(mock_logger.called) - self.assertFalse(mock_call.called) - - @patch('homeassistant.components.conversation.logging.Logger.error') - @patch('homeassistant.core.ServiceRegistry.call') - def test_bad_request_notext(self, mock_logger, mock_call): - """Setup and perform requests with bad command with no text.""" - event_data = {} - self.assertTrue(self.hass.services.call( - conversation.DOMAIN, 'process', event_data, True)) - self.assertTrue(mock_logger.called) - self.assertFalse(mock_call.called) +from tests.common import async_mock_intent, async_mock_service @asyncio.coroutine @@ -248,3 +139,89 @@ def test_http_processing_intent(hass, test_client): } } } + + +@asyncio.coroutine +@pytest.mark.parametrize('sentence', ('turn on kitchen', 'turn kitchen on')) +def test_turn_on_intent(hass, sentence): + """Test calling the turn on intent.""" + result = yield from async_setup_component(hass, 'conversation', {}) + assert result + + hass.states.async_set('light.kitchen', 'off') + calls = async_mock_service(hass, 'homeassistant', 'turn_on') + + yield from hass.services.async_call( + 'conversation', 'process', { + conversation.ATTR_TEXT: sentence + }) + yield from hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'homeassistant' + assert call.service == 'turn_on' + assert call.data == {'entity_id': 'light.kitchen'} + + +@asyncio.coroutine +@pytest.mark.parametrize('sentence', ('turn off kitchen', 'turn kitchen off')) +def test_turn_off_intent(hass, sentence): + """Test calling the turn on intent.""" + result = yield from async_setup_component(hass, 'conversation', {}) + assert result + + hass.states.async_set('light.kitchen', 'on') + calls = async_mock_service(hass, 'homeassistant', 'turn_off') + + yield from hass.services.async_call( + 'conversation', 'process', { + conversation.ATTR_TEXT: sentence + }) + yield from hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'homeassistant' + assert call.service == 'turn_off' + assert call.data == {'entity_id': 'light.kitchen'} + + +@asyncio.coroutine +def test_http_api(hass, test_client): + """Test the HTTP conversation API.""" + result = yield from async_setup_component(hass, 'conversation', {}) + assert result + + client = yield from test_client(hass.http.app) + hass.states.async_set('light.kitchen', 'off') + calls = async_mock_service(hass, 'homeassistant', 'turn_on') + + resp = yield from client.post('/api/conversation/process', json={ + 'text': 'Turn kitchen on' + }) + assert resp.status == 200 + + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'homeassistant' + assert call.service == 'turn_on' + assert call.data == {'entity_id': 'light.kitchen'} + + +@asyncio.coroutine +def test_http_api_wrong_data(hass, test_client): + """Test the HTTP conversation API.""" + result = yield from async_setup_component(hass, 'conversation', {}) + assert result + + client = yield from test_client(hass.http.app) + + resp = yield from client.post('/api/conversation/process', json={ + 'text': 123 + }) + assert resp.status == 400 + + resp = yield from client.post('/api/conversation/process', json={ + }) + assert resp.status == 400