diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 6b306adad5b..f0c4f7bb3e2 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -156,9 +156,10 @@ def async_setup(hass, config): hass.services.async_register( ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) hass.helpers.intent.async_register(intent.ServiceIntentHandler( - intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned on {}")) + intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned {} on")) hass.helpers.intent.async_register(intent.ServiceIntentHandler( - intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, "Turned off {}")) + intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, + "Turned {} off")) hass.helpers.intent.async_register(intent.ServiceIntentHandler( intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}")) diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 9f325f3eb89..e96694ce0a3 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -4,7 +4,6 @@ Support for functionality to have conversations with Home Assistant. For more details about this component, please refer to the documentation at https://home-assistant.io/components/conversation/ """ -import asyncio import logging import re @@ -67,8 +66,7 @@ def async_register(hass, intent_type, utterances): conf.append(_create_matcher(utterance)) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Register the process service.""" config = config.get(DOMAIN, {}) intents = hass.data.get(DOMAIN) @@ -84,49 +82,73 @@ def async_setup(hass, config): conf.extend(_create_matcher(utterance) for utterance in utterances) - @asyncio.coroutine - def process(service): + async def process(service): """Parse text into commands.""" text = service.data[ATTR_TEXT] - yield from _process(hass, text) + try: + await _process(hass, text) + except intent.IntentHandleError as err: + _LOGGER.error('Error processing %s: %s', text, err) hass.services.async_register( DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA) hass.http.register_view(ConversationProcessView) - async_register(hass, intent.INTENT_TURN_ON, - ['Turn {name} on', 'Turn on {name}']) - async_register(hass, intent.INTENT_TURN_OFF, - ['Turn {name} off', 'Turn off {name}']) - async_register(hass, intent.INTENT_TOGGLE, - ['Toggle {name}', '{name} toggle']) + # We strip trailing 's' from name because our state matcher will fail + # if a letter is not there. By removing 's' we can match singular and + # plural names. + + async_register(hass, intent.INTENT_TURN_ON, [ + 'Turn [the] [a] {name}[s] on', + 'Turn on [the] [a] [an] {name}[s]', + ]) + async_register(hass, intent.INTENT_TURN_OFF, [ + 'Turn [the] [a] [an] {name}[s] off', + 'Turn off [the] [a] [an] {name}[s]', + ]) + async_register(hass, intent.INTENT_TOGGLE, [ + 'Toggle [the] [a] [an] {name}[s]', + '[the] [a] [an] {name}[s] toggle', + ]) return True def _create_matcher(utterance): """Create a regex that matches the utterance.""" - parts = re.split(r'({\w+})', utterance) + # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL + # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} + parts = re.split(r'({\w+}|\[[\w\s]+\] *)', utterance) + # Pattern to extract name from GROUP part. Matches {name} group_matcher = re.compile(r'{(\w+)}') + # Pattern to extract text from OPTIONAL part. Matches [the color] + optional_matcher = re.compile(r'\[([\w ]+)\] *') pattern = ['^'] - for part in parts: - match = group_matcher.match(part) + group_match = group_matcher.match(part) + optional_match = optional_matcher.match(part) - if match is None: + # Normal part + if group_match is None and optional_match is None: pattern.append(part) continue - pattern.append('(?P<{}>{})'.format(match.groups()[0], r'[\w ]+')) + # Group part + if group_match is not None: + pattern.append( + r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0])) + + # Optional part + elif optional_match is not None: + pattern.append(r'(?:{} *)?'.format(optional_match.groups()[0])) pattern.append('$') return re.compile(''.join(pattern), re.I) -@asyncio.coroutine -def _process(hass, text): +async def _process(hass, text): """Process a line of text.""" intents = hass.data.get(DOMAIN, {}) @@ -137,7 +159,7 @@ def _process(hass, text): if not match: continue - response = yield from hass.helpers.intent.async_handle( + response = await hass.helpers.intent.async_handle( DOMAIN, intent_type, {key: {'value': value} for key, value in match.groupdict().items()}, text) @@ -153,12 +175,15 @@ class ConversationProcessView(http.HomeAssistantView): @RequestDataValidator(vol.Schema({ vol.Required('text'): str, })) - @asyncio.coroutine - def post(self, request, data): + async def post(self, request, data): """Send a request for processing.""" hass = request.app['hass'] - intent_result = yield from _process(hass, data['text']) + try: + intent_result = await _process(hass, data['text']) + except intent.IntentHandleError as err: + intent_result = intent.IntentResponse() + intent_result.async_set_speech(str(err)) if intent_result is None: intent_result = intent.IntentResponse() diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 2452188a889..da3e2e7147d 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -43,7 +43,7 @@ def async_setup(hass, config): hass.http.register_view(ClearCompletedItemsView) hass.components.conversation.async_register(INTENT_ADD_ITEM, [ - 'Add {item} to my shopping list', + 'Add [the] [a] [an] {item} to my shopping list', ]) hass.components.conversation.async_register(INTENT_LAST_ITEMS, [ 'What is on my shopping list' diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index dac0f4c507b..5aa53f17e7b 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -100,7 +100,8 @@ def async_match_state(hass, name, states=None): state = _fuzzymatch(name, states, lambda state: state.name) if state is None: - raise IntentHandleError('Unable to find entity {}'.format(name)) + raise IntentHandleError( + 'Unable to find an entity called {}'.format(name)) return state diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index 8d629321853..466dc57017a 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -237,7 +237,7 @@ def test_http_api(hass, test_client): calls = async_mock_service(hass, 'homeassistant', 'turn_on') resp = yield from client.post('/api/conversation/process', json={ - 'text': 'Turn kitchen on' + 'text': 'Turn the kitchen on' }) assert resp.status == 200 @@ -267,3 +267,56 @@ def test_http_api_wrong_data(hass, test_client): resp = yield from client.post('/api/conversation/process', json={ }) assert resp.status == 400 + + +def test_create_matcher(): + """Test the create matcher method.""" + # Basic sentence + pattern = conversation._create_matcher('Hello world') + assert pattern.match('Hello world') is not None + + # Match a part + pattern = conversation._create_matcher('Hello {name}') + match = pattern.match('hello world') + assert match is not None + assert match.groupdict()['name'] == 'world' + no_match = pattern.match('Hello world, how are you?') + assert no_match is None + + # Optional and matching part + pattern = conversation._create_matcher('Turn on [the] {name}') + match = pattern.match('turn on the kitchen lights') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn on kitchen lights') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn off kitchen lights') + assert match is None + + # Two different optional parts, 1 matching part + pattern = conversation._create_matcher('Turn on [the] [a] {name}') + match = pattern.match('turn on the kitchen lights') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn on kitchen lights') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn on a kitchen light') + assert match is not None + assert match.groupdict()['name'] == 'kitchen light' + + # Strip plural + pattern = conversation._create_matcher('Turn {name}[s] on') + match = pattern.match('turn kitchen lights on') + assert match is not None + assert match.groupdict()['name'] == 'kitchen light' + + # Optional 2 words + pattern = conversation._create_matcher('Turn [the great] {name} on') + match = pattern.match('turn the great kitchen lights on') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn kitchen lights on') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' diff --git a/tests/components/test_init.py b/tests/components/test_init.py index 2005f658a71..eca4763b4b3 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -212,7 +212,7 @@ def test_turn_on_intent(hass): ) yield from hass.async_block_till_done() - assert response.speech['plain']['speech'] == 'Turned on test light' + assert response.speech['plain']['speech'] == 'Turned test light on' assert len(calls) == 1 call = calls[0] assert call.domain == 'light' @@ -234,7 +234,7 @@ def test_turn_off_intent(hass): ) yield from hass.async_block_till_done() - assert response.speech['plain']['speech'] == 'Turned off test light' + assert response.speech['plain']['speech'] == 'Turned test light off' assert len(calls) == 1 call = calls[0] assert call.domain == 'light' @@ -283,7 +283,7 @@ def test_turn_on_multiple_intent(hass): ) yield from hass.async_block_till_done() - assert response.speech['plain']['speech'] == 'Turned on test lights 2' + assert response.speech['plain']['speech'] == 'Turned test lights 2 on' assert len(calls) == 1 call = calls[0] assert call.domain == 'light'