diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py index 1a3708fb746..c121268f93d 100644 --- a/homeassistant/components/alexa.py +++ b/homeassistant/components/alexa.py @@ -15,8 +15,8 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.const import HTTP_BAD_REQUEST -from homeassistant.helpers import template, script, config_validation as cv -from homeassistant.components.http import HomeAssistantView +from homeassistant.helpers import intent, template, config_validation as cv +from homeassistant.components import http _LOGGER = logging.getLogger(__name__) @@ -60,6 +60,12 @@ class SpeechType(enum.Enum): ssml = "SSML" +SPEECH_MAPPINGS = { + 'plain': SpeechType.plaintext, + 'ssml': SpeechType.ssml, +} + + class CardType(enum.Enum): """The Alexa card types.""" @@ -69,20 +75,6 @@ class CardType(enum.Enum): CONFIG_SCHEMA = vol.Schema({ DOMAIN: { - CONF_INTENTS: { - cv.string: { - vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_CARD): { - vol.Required(CONF_TYPE): cv.enum(CardType), - vol.Required(CONF_TITLE): cv.template, - vol.Required(CONF_CONTENT): cv.template, - }, - vol.Optional(CONF_SPEECH): { - vol.Required(CONF_TYPE): cv.enum(SpeechType), - vol.Required(CONF_TEXT): cv.template, - } - } - }, CONF_FLASH_BRIEFINGS: { cv.string: vol.All(cv.ensure_list, [{ vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string, @@ -96,40 +88,27 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Activate Alexa component.""" - intents = config[DOMAIN].get(CONF_INTENTS, {}) flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {}) - hass.http.register_view(AlexaIntentsView(hass, intents)) + hass.http.register_view(AlexaIntentsView) hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings)) return True -class AlexaIntentsView(HomeAssistantView): +class AlexaIntentsView(http.HomeAssistantView): """Handle Alexa requests.""" url = INTENTS_API_ENDPOINT name = 'api:alexa' - def __init__(self, hass, intents): - """Initialize Alexa view.""" - super().__init__() - - intents = copy.deepcopy(intents) - template.attach(hass, intents) - - for name, intent in intents.items(): - if CONF_ACTION in intent: - intent[CONF_ACTION] = script.Script( - hass, intent[CONF_ACTION], "Alexa intent {}".format(name)) - - self.intents = intents - @asyncio.coroutine def post(self, request): """Handle Alexa.""" + hass = request.app['hass'] data = yield from request.json() _LOGGER.debug('Received Alexa request: %s', data) @@ -146,14 +125,14 @@ class AlexaIntentsView(HomeAssistantView): if req_type == 'SessionEndedRequest': return None - intent = req.get('intent') - response = AlexaResponse(request.app['hass'], intent) + alexa_intent_info = req.get('intent') + alexa_response = AlexaResponse(hass, alexa_intent_info) if req_type == 'LaunchRequest': - response.add_speech( + alexa_response.add_speech( SpeechType.plaintext, "Hello, and welcome to the future. How may I help?") - return self.json(response) + return self.json(alexa_response) if req_type != 'IntentRequest': _LOGGER.warning('Received unsupported request: %s', req_type) @@ -161,38 +140,47 @@ class AlexaIntentsView(HomeAssistantView): 'Received unsupported request: {}'.format(req_type), HTTP_BAD_REQUEST) - intent_name = intent['name'] - config = self.intents.get(intent_name) + intent_name = alexa_intent_info['name'] - if config is None: + try: + intent_response = yield from intent.async_handle( + hass, DOMAIN, intent_name, + {key: {'value': value} for key, value + in alexa_response.variables.items()}) + except intent.UnknownIntent as err: _LOGGER.warning('Received unknown intent %s', intent_name) - response.add_speech( + alexa_response.add_speech( SpeechType.plaintext, "This intent is not yet configured within Home Assistant.") - return self.json(response) + return self.json(alexa_response) - speech = config.get(CONF_SPEECH) - card = config.get(CONF_CARD) - action = config.get(CONF_ACTION) + except intent.InvalidSlotInfo as err: + _LOGGER.error('Received invalid slot data from Alexa: %s', err) + return self.json_message('Invalid slot data received', + HTTP_BAD_REQUEST) + except intent.IntentError: + _LOGGER.exception('Error handling request for %s', intent_name) + return self.json_message('Error handling intent', HTTP_BAD_REQUEST) - if action is not None: - yield from action.async_run(response.variables) + for intent_speech, alexa_speech in SPEECH_MAPPINGS.items(): + if intent_speech in intent_response.speech: + alexa_response.add_speech( + alexa_speech, + intent_response.speech[intent_speech]['speech']) + break - # pylint: disable=unsubscriptable-object - if speech is not None: - response.add_speech(speech[CONF_TYPE], speech[CONF_TEXT]) + if 'simple' in intent_response.card: + alexa_response.add_card( + 'simple', intent_response.card['simple']['title'], + intent_response.card['simple']['content']) - if card is not None: - response.add_card(card[CONF_TYPE], card[CONF_TITLE], - card[CONF_CONTENT]) - - return self.json(response) + return self.json(alexa_response) class AlexaResponse(object): """Help generating the response for Alexa.""" - def __init__(self, hass, intent=None): + def __init__(self, hass, intent_info): """Initialize the response.""" self.hass = hass self.speech = None @@ -201,8 +189,9 @@ class AlexaResponse(object): self.session_attributes = {} self.should_end_session = True self.variables = {} - if intent is not None and 'slots' in intent: - for key, value in intent['slots'].items(): + # Intent is None if request was a LaunchRequest or SessionEndedRequest + if intent_info is not None: + for key, value in intent_info.get('slots', {}).items(): if 'value' in value: underscored_key = key.replace('.', '_') self.variables[underscored_key] = value['value'] @@ -272,7 +261,7 @@ class AlexaResponse(object): } -class AlexaFlashBriefingView(HomeAssistantView): +class AlexaFlashBriefingView(http.HomeAssistantView): """Handle Alexa Flash Briefing skill requests.""" url = FLASH_BRIEFINGS_API_ENDPOINT diff --git a/homeassistant/components/apiai.py b/homeassistant/components/apiai.py index 989c1a596f3..eb6cd0027f7 100644 --- a/homeassistant/components/apiai.py +++ b/homeassistant/components/apiai.py @@ -5,13 +5,12 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/apiai/ """ import asyncio -import copy import logging import voluptuous as vol from homeassistant.const import PROJECT_NAME, HTTP_BAD_REQUEST -from homeassistant.helpers import template, script, config_validation as cv +from homeassistant.helpers import intent, template from homeassistant.components.http import HomeAssistantView _LOGGER = logging.getLogger(__name__) @@ -29,24 +28,14 @@ DOMAIN = 'apiai' DEPENDENCIES = ['http'] CONFIG_SCHEMA = vol.Schema({ - DOMAIN: { - CONF_INTENTS: { - cv.string: { - vol.Optional(CONF_SPEECH): cv.template, - vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ASYNC_ACTION, - default=DEFAULT_CONF_ASYNC_ACTION): cv.boolean - } - } - } + DOMAIN: {} }, extra=vol.ALLOW_EXTRA) -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Activate API.AI component.""" - intents = config[DOMAIN].get(CONF_INTENTS, {}) - - hass.http.register_view(ApiaiIntentsView(hass, intents)) + hass.http.register_view(ApiaiIntentsView) return True @@ -57,24 +46,10 @@ class ApiaiIntentsView(HomeAssistantView): url = INTENTS_API_ENDPOINT name = 'api:apiai' - def __init__(self, hass, intents): - """Initialize API.AI view.""" - super().__init__() - - self.hass = hass - intents = copy.deepcopy(intents) - template.attach(hass, intents) - - for name, intent in intents.items(): - if CONF_ACTION in intent: - intent[CONF_ACTION] = script.Script( - hass, intent[CONF_ACTION], "Apiai intent {}".format(name)) - - self.intents = intents - @asyncio.coroutine def post(self, request): """Handle API.AI.""" + hass = request.app['hass'] data = yield from request.json() _LOGGER.debug("Received api.ai request: %s", data) @@ -91,55 +66,41 @@ class ApiaiIntentsView(HomeAssistantView): if action_incomplete: return None - # use intent to no mix HASS actions with this parameter - intent = req.get('action') + action = req.get('action') parameters = req.get('parameters') - # contexts = req.get('contexts') - response = ApiaiResponse(parameters) + apiai_response = ApiaiResponse(parameters) - # Default Welcome Intent - # Maybe is better to handle this in api.ai directly? - # - # if intent == 'input.welcome': - # response.add_speech( - # "Hello, and welcome to the future. How may I help?") - # return self.json(response) - - if intent == "": + if action == "": _LOGGER.warning("Received intent with empty action") - response.add_speech( + apiai_response.add_speech( "You have not defined an action in your api.ai intent.") - return self.json(response) + return self.json(apiai_response) - config = self.intents.get(intent) + try: + intent_response = yield from intent.async_handle( + hass, DOMAIN, action, + {key: {'value': value} for key, value + in parameters.items()}) - if config is None: - _LOGGER.warning("Received unknown intent %s", intent) - response.add_speech( - "Intent '%s' is not yet configured within Home Assistant." % - intent) - return self.json(response) + except intent.UnknownIntent as err: + _LOGGER.warning('Received unknown intent %s', action) + apiai_response.add_speech( + "This intent is not yet configured within Home Assistant.") + return self.json(apiai_response) - speech = config.get(CONF_SPEECH) - action = config.get(CONF_ACTION) - async_action = config.get(CONF_ASYNC_ACTION) + except intent.InvalidSlotInfo as err: + _LOGGER.error('Received invalid slot data: %s', err) + return self.json_message('Invalid slot data received', + HTTP_BAD_REQUEST) + except intent.IntentError: + _LOGGER.exception('Error handling request for %s', action) + return self.json_message('Error handling intent', HTTP_BAD_REQUEST) - if action is not None: - # API.AI expects a response in less than 5s - if async_action: - # Do not wait for the action to be executed. - # Needed if the action will take longer than 5s to execute - self.hass.async_add_job(action.async_run(response.parameters)) - else: - # Wait for the action to be executed so we can use results to - # render the answer - yield from action.async_run(response.parameters) + if 'plain' in intent_response.speech: + apiai_response.add_speech( + intent_response.speech['plain']['speech']) - # pylint: disable=unsubscriptable-object - if speech is not None: - response.add_speech(speech) - - return self.json(response) + return self.json(apiai_response) class ApiaiResponse(object): diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 591190383a0..b682b318c7b 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -4,6 +4,7 @@ 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 import warnings @@ -11,16 +12,17 @@ import warnings 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) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import script + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, HTTP_BAD_REQUEST) +from homeassistant.helpers import intent, config_validation as cv +from homeassistant.components import http REQUIREMENTS = ['fuzzywuzzy==0.15.0'] +DEPENDENCIES = ['http'] ATTR_TEXT = 'text' -ATTR_SENTENCE = 'sentence' DOMAIN = 'conversation' REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') @@ -28,79 +30,168 @@ REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') SERVICE_PROCESS = 'process' SERVICE_PROCESS_SCHEMA = vol.Schema({ - vol.Required(ATTR_TEXT): vol.All(cv.string, vol.Lower), + vol.Required(ATTR_TEXT): cv.string, }) CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({ - cv.string: vol.Schema({ - vol.Required(ATTR_SENTENCE): cv.string, - vol.Required('action'): cv.SCRIPT_SCHEMA, + vol.Optional('intents'): vol.Schema({ + cv.string: vol.All(cv.ensure_list, [cv.string]) }) })}, extra=vol.ALLOW_EXTRA) +_LOGGER = logging.getLogger(__name__) -def setup(hass, config): + +@core.callback +@bind_hass +def async_register(hass, intent_type, utterances): + """Register an intent. + + Registrations don't require conversations to be loaded. They will become + active once the conversation component is loaded. + """ + intents = hass.data.get(DOMAIN) + + if intents is None: + intents = hass.data[DOMAIN] = {} + + conf = intents.get(intent_type) + + if conf is None: + conf = intents[intent_type] = [] + + conf.extend(_create_matcher(utterance) for utterance in utterances) + + +@asyncio.coroutine +def async_setup(hass, config): """Register the process service.""" warnings.filterwarnings('ignore', module='fuzzywuzzy') - from fuzzywuzzy import process as fuzzyExtract - logger = logging.getLogger(__name__) config = config.get(DOMAIN, {}) + intents = hass.data.get(DOMAIN) - choices = {attrs[ATTR_SENTENCE]: script.Script( - hass, - attrs['action'], - name) - for name, attrs in config.items()} + if intents is None: + intents = hass.data[DOMAIN] = {} + for intent_type, utterances in config.get('intents', {}).items(): + conf = intents.get(intent_type) + + if conf is None: + conf = intents[intent_type] = [] + + conf.extend(_create_matcher(utterance) for utterance in utterances) + + @asyncio.coroutine def process(service): """Parse text into commands.""" - # if actually configured - if choices: - text = service.data[ATTR_TEXT] - match = fuzzyExtract.extractOne(text, choices.keys()) - scorelimit = 60 # arbitrary value - logging.info( - 'matched up text %s and found %s', - text, - [match[0] if match[1] > scorelimit else 'nothing'] - ) - if match[1] > scorelimit: - choices[match[0]].run() # run respective script - return - text = service.data[ATTR_TEXT] - match = REGEX_TURN_COMMAND.match(text) + yield from _process(hass, text) - if not match: - logger.error("Unable to process: %s", text) - return - - name, command = match.groups() - entities = {state.entity_id: state.name for state in hass.states.all()} - entity_ids = fuzzyExtract.extractOne( - name, entities, score_cutoff=65)[2] - - if not entity_ids: - logger.error( - "Could not find entity id %s from text %s", name, text) - return - - if command == 'on': - hass.services.call(core.DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity_ids, - }, blocking=True) - - elif command == 'off': - hass.services.call(core.DOMAIN, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: entity_ids, - }, blocking=True) - - else: - logger.error('Got unsupported command %s from text %s', - command, text) - - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA) + hass.http.register_view(ConversationProcessView) + return True + + +def _create_matcher(utterance): + """Create a regex that matches the utterance.""" + parts = re.split(r'({\w+})', utterance) + group_matcher = re.compile(r'{(\w+)}') + + pattern = ['^'] + + for part in parts: + match = group_matcher.match(part) + + if match is None: + pattern.append(part) + continue + + pattern.append('(?P<{}>{})'.format(match.groups()[0], r'[\w ]+')) + + pattern.append('$') + return re.compile(''.join(pattern), re.I) + + +@asyncio.coroutine +def _process(hass, text): + """Process a line of text.""" + intents = hass.data.get(DOMAIN, {}) + + for intent_type, matchers in intents.items(): + for matcher in matchers: + match = matcher.match(text) + + if not match: + continue + + response = yield from intent.async_handle( + hass, DOMAIN, intent_type, + {key: {'value': value} for key, value + in match.groupdict().items()}, text) + return response + + 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( + name, entities, score_cutoff=65)[2] + + if not entity_ids: + _LOGGER.error( + "Could not find entity id %s from text %s", name, text) + return + + if command == 'on': + yield from hass.services.async_call( + core.DOMAIN, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity_ids, + }, blocking=True) + + elif command == 'off': + yield from hass.services.async_call( + core.DOMAIN, SERVICE_TURN_OFF, { + ATTR_ENTITY_ID: entity_ids, + }, blocking=True) + + else: + _LOGGER.error('Got unsupported command %s from text %s', + command, text) + + +class ConversationProcessView(http.HomeAssistantView): + """View to retrieve shopping list content.""" + + url = '/api/conversation/process' + name = "api:conversation:process" + + @asyncio.coroutine + def post(self, request): + """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) + + return self.json(intent_result) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 443ff6f3852..2674eb062a8 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -12,6 +12,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.config import find_config_file, load_yaml_config_file from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback +from homeassistant.loader import bind_hass from homeassistant.components import api from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.auth import is_trusted_ip @@ -75,6 +76,7 @@ SERVICE_SET_THEME_SCHEMA = vol.Schema({ }) +@bind_hass def register_built_in_panel(hass, component_name, sidebar_title=None, sidebar_icon=None, url_path=None, config=None): """Register a built-in panel.""" @@ -96,6 +98,7 @@ def register_built_in_panel(hass, component_name, sidebar_title=None, sidebar_icon, url_path, url, config) +@bind_hass def register_panel(hass, component_name, path, md5=None, sidebar_title=None, sidebar_icon=None, url_path=None, url=None, config=None): """Register a panel for the frontend. diff --git a/homeassistant/components/intent_script.py b/homeassistant/components/intent_script.py new file mode 100644 index 00000000000..91489e188c5 --- /dev/null +++ b/homeassistant/components/intent_script.py @@ -0,0 +1,100 @@ +"""Handle intents with scripts.""" +import asyncio +import copy +import logging + +import voluptuous as vol + +from homeassistant.helpers import ( + intent, template, script, config_validation as cv) + +DOMAIN = 'intent_script' + +CONF_INTENTS = 'intents' +CONF_SPEECH = 'speech' + +CONF_ACTION = 'action' +CONF_CARD = 'card' +CONF_TYPE = 'type' +CONF_TITLE = 'title' +CONF_CONTENT = 'content' +CONF_TEXT = 'text' +CONF_ASYNC_ACTION = 'async_action' + +DEFAULT_CONF_ASYNC_ACTION = False + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + cv.string: { + vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ASYNC_ACTION, + default=DEFAULT_CONF_ASYNC_ACTION): cv.boolean, + vol.Optional(CONF_CARD): { + vol.Optional(CONF_TYPE, default='simple'): cv.string, + vol.Required(CONF_TITLE): cv.template, + vol.Required(CONF_CONTENT): cv.template, + }, + vol.Optional(CONF_SPEECH): { + vol.Optional(CONF_TYPE, default='plain'): cv.string, + vol.Required(CONF_TEXT): cv.template, + } + } + } +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass, config): + """Activate Alexa component.""" + intents = copy.deepcopy(config[DOMAIN]) + template.attach(hass, intents) + + for intent_type, conf in intents.items(): + if CONF_ACTION in conf: + conf[CONF_ACTION] = script.Script( + hass, conf[CONF_ACTION], + "Intent Script {}".format(intent_type)) + intent.async_register(hass, ScriptIntentHandler(intent_type, conf)) + + return True + + +class ScriptIntentHandler(intent.IntentHandler): + """Respond to an intent with a script.""" + + def __init__(self, intent_type, config): + """Initialize the script intent handler.""" + self.intent_type = intent_type + self.config = config + + @asyncio.coroutine + def async_handle(self, intent_obj): + """Handle the intent.""" + speech = self.config.get(CONF_SPEECH) + card = self.config.get(CONF_CARD) + action = self.config.get(CONF_ACTION) + is_async_action = self.config.get(CONF_ASYNC_ACTION) + slots = {key: value['value'] for key, value + in intent_obj.slots.items()} + + if action is not None: + if is_async_action: + intent_obj.hass.async_add_job(action.async_run(slots)) + else: + yield from action.async_run(slots) + + response = intent_obj.create_response() + + if speech is not None: + response.async_set_speech(speech[CONF_TEXT].async_render(slots), + speech[CONF_TYPE]) + + if card is not None: + response.async_set_card( + card[CONF_TITLE].async_render(slots), + card[CONF_CONTENT].async_render(slots), + card[CONF_TYPE]) + + return response diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py new file mode 100644 index 00000000000..7247579fa39 --- /dev/null +++ b/homeassistant/components/shopping_list.py @@ -0,0 +1,90 @@ +"""Component to manage a shoppling list.""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components import http +from homeassistant.helpers import intent +import homeassistant.helpers.config_validation as cv + + +DOMAIN = 'shopping_list' +DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) +EVENT = 'shopping_list_updated' +INTENT_ADD_ITEM = 'HassShoppingListAddItem' +INTENT_LAST_ITEMS = 'HassShoppingListLastItems' + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the shopping list.""" + hass.data[DOMAIN] = [] + intent.async_register(hass, AddItemIntent()) + intent.async_register(hass, ListTopItemsIntent()) + hass.http.register_view(ShoppingListView) + hass.components.conversation.async_register(INTENT_ADD_ITEM, [ + 'Add {item} to my shopping list', + ]) + hass.components.conversation.async_register(INTENT_LAST_ITEMS, [ + 'What is on my shopping list' + ]) + hass.components.frontend.register_built_in_panel( + 'shopping-list', 'Shopping List', 'mdi:cart') + return True + + +class AddItemIntent(intent.IntentHandler): + """Handle AddItem intents.""" + + intent_type = INTENT_ADD_ITEM + slot_schema = { + 'item': cv.string + } + + @asyncio.coroutine + def async_handle(self, intent_obj): + """Handle the intent.""" + slots = self.async_validate_slots(intent_obj.slots) + item = slots['item']['value'] + intent_obj.hass.data[DOMAIN].append(item) + + response = intent_obj.create_response() + response.async_set_speech( + "I've added {} to your shopping list".format(item)) + intent_obj.hass.bus.async_fire(EVENT) + return response + + +class ListTopItemsIntent(intent.IntentHandler): + """Handle AddItem intents.""" + + intent_type = INTENT_LAST_ITEMS + slot_schema = { + 'item': cv.string + } + + @asyncio.coroutine + def async_handle(self, intent_obj): + """Handle the intent.""" + response = intent_obj.create_response() + response.async_set_speech( + "These are the top 5 items in your shopping list: {}".format( + ', '.join(reversed(intent_obj.hass.data[DOMAIN][-5:])))) + intent_obj.hass.bus.async_fire(EVENT) + return response + + +class ShoppingListView(http.HomeAssistantView): + """View to retrieve shopping list content.""" + + url = '/api/shopping_list' + name = "api:shopping_list" + + @callback + def get(self, request): + """Retrieve if API is running.""" + return self.json(request.app['hass'].data[DOMAIN]) diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index b123de48158..6243de0b2d6 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -5,12 +5,10 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/snips/ """ import asyncio -import copy import json import logging import voluptuous as vol -from homeassistant.helpers import template, script, config_validation as cv -import homeassistant.loader as loader +from homeassistant.helpers import intent, config_validation as cv DOMAIN = 'snips' DEPENDENCIES = ['mqtt'] @@ -19,16 +17,10 @@ CONF_ACTION = 'action' INTENT_TOPIC = 'hermes/nlu/intentParsed' -LOGGER = logging.getLogger(__name__) +_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: { - CONF_INTENTS: { - cv.string: { - vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, - } - } - } + DOMAIN: {} }, extra=vol.ALLOW_EXTRA) INTENT_SCHEMA = vol.Schema({ @@ -49,74 +41,34 @@ INTENT_SCHEMA = vol.Schema({ @asyncio.coroutine def async_setup(hass, config): """Activate Snips component.""" - mqtt = loader.get_component('mqtt') - intents = config[DOMAIN].get(CONF_INTENTS, {}) - handler = IntentHandler(hass, intents) - @asyncio.coroutine def message_received(topic, payload, qos): """Handle new messages on MQTT.""" - LOGGER.debug("New intent: %s", payload) - yield from handler.handle_intent(payload) + _LOGGER.debug("New intent: %s", payload) - yield from mqtt.async_subscribe(hass, INTENT_TOPIC, message_received) + try: + request = json.loads(payload) + except TypeError: + _LOGGER.error('Received invalid JSON: %s', payload) + return + + try: + request = INTENT_SCHEMA(request) + except vol.Invalid as err: + _LOGGER.error('Intent has invalid schema: %s. %s', err, request) + return + + intent_type = request['intent']['intentName'].split('__')[-1] + slots = {slot['slotName']: {'value': slot['value']['value']} + for slot in request.get('slots', [])} + + try: + yield from intent.async_handle( + hass, DOMAIN, intent_type, slots, request['input']) + except intent.IntentError: + _LOGGER.exception("Error while handling intent.") + + yield from hass.components.mqtt.async_subscribe( + INTENT_TOPIC, message_received) return True - - -class IntentHandler(object): - """Help handling intents.""" - - def __init__(self, hass, intents): - """Initialize the intent handler.""" - self.hass = hass - intents = copy.deepcopy(intents) - template.attach(hass, intents) - - for name, intent in intents.items(): - if CONF_ACTION in intent: - intent[CONF_ACTION] = script.Script( - hass, intent[CONF_ACTION], "Snips intent {}".format(name)) - - self.intents = intents - - @asyncio.coroutine - def handle_intent(self, payload): - """Handle an intent.""" - try: - response = json.loads(payload) - except TypeError: - LOGGER.error('Received invalid JSON: %s', payload) - return - - try: - response = INTENT_SCHEMA(response) - except vol.Invalid as err: - LOGGER.error('Intent has invalid schema: %s. %s', err, response) - return - - intent = response['intent']['intentName'].split('__')[-1] - config = self.intents.get(intent) - - if config is None: - LOGGER.warning("Received unknown intent %s. %s", intent, response) - return - - action = config.get(CONF_ACTION) - - if action is not None: - slots = self.parse_slots(response) - yield from action.async_run(slots) - - # pylint: disable=no-self-use - def parse_slots(self, response): - """Parse the intent slots.""" - parameters = {} - - for slot in response.get('slots', []): - key = slot['slotName'] - value = slot['value']['value'] - if value is not None: - parameters[key] = value - - return parameters diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py new file mode 100644 index 00000000000..459d89a83ce --- /dev/null +++ b/homeassistant/helpers/intent.py @@ -0,0 +1,165 @@ +"""Module to coordinate user intentions.""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError + + +DATA_KEY = 'intent' +_LOGGER = logging.getLogger(__name__) + +SLOT_SCHEMA = vol.Schema({ +}, extra=vol.ALLOW_EXTRA) + +SPEECH_TYPE_PLAIN = 'plain' +SPEECH_TYPE_SSML = 'ssml' + + +@callback +def async_register(hass, handler): + """Register an intent with Home Assistant.""" + intents = hass.data.get(DATA_KEY) + if intents is None: + intents = hass.data[DATA_KEY] = {} + + if handler.intent_type in intents: + _LOGGER.warning('Intent %s is being overwritten by %s.', + handler.intent_type, handler) + + intents[handler.intent_type] = handler + + +@asyncio.coroutine +def async_handle(hass, platform, intent_type, slots=None, text_input=None): + """Handle an intent.""" + handler = hass.data.get(DATA_KEY, {}).get(intent_type) + + if handler is None: + raise UnknownIntent() + + intent = Intent(hass, platform, intent_type, slots or {}, text_input) + + try: + _LOGGER.info("Triggering intent handler %s", handler) + result = yield from handler.async_handle(intent) + return result + except vol.Invalid as err: + raise InvalidSlotInfo from err + except Exception as err: + raise IntentHandleError from err + + +class IntentError(HomeAssistantError): + """Base class for intent related errors.""" + + pass + + +class UnknownIntent(IntentError): + """When the intent is not registered.""" + + pass + + +class InvalidSlotInfo(IntentError): + """When the slot data is invalid.""" + + pass + + +class IntentHandleError(IntentError): + """Error while handling intent.""" + + pass + + +class IntentHandler: + """Intent handler registration.""" + + intent_type = None + slot_schema = None + _slot_schema = None + platforms = None + + @callback + def async_can_handle(self, intent_obj): + """Test if an intent can be handled.""" + return self.platforms is None or intent_obj.platform in self.platforms + + @callback + def async_validate_slots(self, slots): + """Validate slot information.""" + if self.slot_schema is None: + return slots + + if self._slot_schema is None: + self._slot_schema = vol.Schema({ + key: SLOT_SCHEMA.extend({'value': validator}) + for key, validator in self.slot_schema.items()}) + + return self._slot_schema(slots) + + @asyncio.coroutine + def async_handle(self, intent_obj): + """Handle the intent.""" + raise NotImplementedError() + + def __repr__(self): + """String representation of intent handler.""" + return '<{} - {}>'.format(self.__class__.__name__, self.intent_type) + + +class Intent: + """Hold the intent.""" + + __slots__ = ['hass', 'platform', 'intent_type', 'slots', 'text_input'] + + def __init__(self, hass, platform, intent_type, slots, text_input): + """Initialize an intent.""" + self.hass = hass + self.platform = platform + self.intent_type = intent_type + self.slots = slots + self.text_input = text_input + + @callback + def create_response(self): + """Create a response.""" + return IntentResponse(self) + + +class IntentResponse: + """Response to an intent.""" + + def __init__(self, intent): + """Initialize an IntentResponse.""" + self.intent = intent + self.speech = {} + self.card = {} + + @callback + def async_set_speech(self, speech, speech_type='plain', extra_data=None): + """Set speech response.""" + self.speech[speech_type] = { + 'speech': speech, + 'extra_data': extra_data, + } + + @callback + def async_set_card(self, title, content, card_type='simple'): + """Set speech response.""" + self.card[card_type] = { + 'title': title, + 'content': content, + } + + @callback + def as_dict(self): + """Return a dictionary representation of an intent response.""" + return { + 'speech': self.speech, + 'card': self.card, + } diff --git a/tests/common.py b/tests/common.py index b2001a3c837..5e328959a7a 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,9 +14,7 @@ from aiohttp import web from homeassistant import core as ha, loader from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import ToggleEntity -from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE +from homeassistant.helpers import intent, dispatcher, entity, restore_state from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.dt as date_util import homeassistant.util.yaml as yaml @@ -193,12 +191,31 @@ def async_mock_service(hass, domain, service): mock_service = threadsafe_callback_factory(async_mock_service) +@ha.callback +def async_mock_intent(hass, intent_typ): + """Set up a fake intent handler.""" + intents = [] + + class MockIntentHandler(intent.IntentHandler): + intent_type = intent_typ + + @asyncio.coroutine + def async_handle(self, intent): + """Handle the intent.""" + intents.append(intent) + return intent.create_response() + + intent.async_register(hass, MockIntentHandler()) + + return intents + + @ha.callback def async_fire_mqtt_message(hass, topic, payload, qos=0): """Fire the MQTT message.""" if isinstance(payload, str): payload = payload.encode('utf-8') - async_dispatcher_send( + dispatcher.async_dispatcher_send( hass, mqtt.SIGNAL_MQTT_MESSAGE_RECEIVED, topic, payload, qos) @@ -352,7 +369,7 @@ class MockPlatform(object): self._setup_platform(hass, config, add_devices, discovery_info) -class MockToggleDevice(ToggleEntity): +class MockToggleDevice(entity.ToggleEntity): """Provide a mock toggle device.""" def __init__(self, name, state): @@ -506,10 +523,11 @@ def init_recorder_component(hass, add_config=None): def mock_restore_cache(hass, states): """Mock the DATA_RESTORE_CACHE.""" - hass.data[DATA_RESTORE_CACHE] = { + key = restore_state.DATA_RESTORE_CACHE + hass.data[key] = { state.entity_id: state for state in states} - _LOGGER.debug('Restore cache: %s', hass.data[DATA_RESTORE_CACHE]) - assert len(hass.data[DATA_RESTORE_CACHE]) == len(states), \ + _LOGGER.debug('Restore cache: %s', hass.data[key]) + assert len(hass.data[key]) == len(states), \ "Duplicate entity_id? {}".format(states) hass.state = ha.CoreState.starting mock_component(hass, recorder.DOMAIN) diff --git a/tests/components/test_alexa.py b/tests/components/test_alexa.py index 47a3e086d29..eb8391c3e0d 100644 --- a/tests/components/test_alexa.py +++ b/tests/components/test_alexa.py @@ -47,10 +47,14 @@ def alexa_client(loop, hass, test_client): "uid": "uuid" } }, - "intents": { + } + })) + assert loop.run_until_complete(async_setup_component( + hass, 'intent_script', { + 'intent_script': { "WhereAreWeIntent": { "speech": { - "type": "plaintext", + "type": "plain", "text": """ {%- if is_state("device_tracker.paulus", "home") @@ -69,19 +73,19 @@ def alexa_client(loop, hass, test_client): }, "GetZodiacHoroscopeIntent": { "speech": { - "type": "plaintext", + "type": "plain", "text": "You told us your sign is {{ ZodiacSign }}.", } }, "AMAZON.PlaybackAction": { "speech": { - "type": "plaintext", + "type": "plain", "text": "Playing {{ object_byArtist_name }}.", } }, "CallServiceIntent": { "speech": { - "type": "plaintext", + "type": "plain", "text": "Service called", }, "action": { @@ -93,8 +97,7 @@ def alexa_client(loop, hass, test_client): } } } - } - })) + })) return loop.run_until_complete(test_client(hass.http.app)) diff --git a/tests/components/test_apiai.py b/tests/components/test_apiai.py index f5624f3c209..0c15326bbfc 100644 --- a/tests/components/test_apiai.py +++ b/tests/components/test_apiai.py @@ -57,14 +57,15 @@ def setUpModule(): hass.services.register("test", "apiai", mock_service) - setup.setup_component(hass, apiai.DOMAIN, { - # Key is here to verify we allow other keys in config too - "homeassistant": {}, - "apiai": { - "intents": { - "WhereAreWeIntent": { - "speech": - """ + assert setup.setup_component(hass, apiai.DOMAIN, { + "apiai": {}, + }) + assert setup.setup_component(hass, "intent_script", { + "intent_script": { + "WhereAreWeIntent": { + "speech": { + "type": "plain", + "text": """ {%- if is_state("device_tracker.paulus", "home") and is_state("device_tracker.anne_therese", "home") -%} @@ -77,19 +78,25 @@ def setUpModule(): }} {% endif %} """, + } + }, + "GetZodiacHoroscopeIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign }}.", + } + }, + "CallServiceIntent": { + "speech": { + "type": "plain", + "text": "Service called", }, - "GetZodiacHoroscopeIntent": { - "speech": "You told us your sign is {{ ZodiacSign }}.", - }, - "CallServiceIntent": { - "speech": "Service called", - "action": { - "service": "test.apiai", - "data_template": { - "hello": "{{ ZodiacSign }}" - }, - "entity_id": "switch.test", - } + "action": { + "service": "test.apiai", + "data_template": { + "hello": "{{ ZodiacSign }}" + }, + "entity_id": "switch.test", } } } @@ -509,5 +516,4 @@ class TestApiai(unittest.TestCase): self.assertEqual(200, req.status_code) text = req.json().get("speech") self.assertEqual( - "Intent 'unknown' is not yet configured within Home Assistant.", - text) + "This intent is not yet configured within Home Assistant.", text) diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index 65ce95ee8e9..138ae1668f8 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -1,16 +1,18 @@ """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 +from homeassistant.setup import setup_component, async_setup_component import homeassistant.components as core_components 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, assert_setup_component +from tests.common import get_test_home_assistant, async_mock_intent class TestConversation(unittest.TestCase): @@ -25,10 +27,9 @@ class TestConversation(unittest.TestCase): self.assertTrue(run_coroutine_threadsafe( core_components.async_setup(self.hass, {}), self.hass.loop ).result()) - with assert_setup_component(0): - self.assertTrue(setup_component(self.hass, conversation.DOMAIN, { - conversation.DOMAIN: {} - })) + self.assertTrue(setup_component(self.hass, conversation.DOMAIN, { + conversation.DOMAIN: {} + })) # pylint: disable=invalid-name def tearDown(self): @@ -119,44 +120,131 @@ class TestConversation(unittest.TestCase): self.assertFalse(mock_call.called) -class TestConfiguration(unittest.TestCase): - """Test the conversation configuration component.""" +@asyncio.coroutine +def test_calling_intent(hass): + """Test calling an intent from a conversation.""" + intents = async_mock_intent(hass, 'OrderBeer') - # pylint: disable=invalid-name - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.assertTrue(setup_component(self.hass, conversation.DOMAIN, { - conversation.DOMAIN: { - 'test_2': { - 'sentence': 'switch boolean', - 'action': { - 'service': 'input_boolean.toggle' - } - } + result = yield from async_setup_component(hass, 'conversation', { + 'conversation': { + 'intents': { + 'OrderBeer': [ + 'I would like the {type} beer' + ] } - })) + } + }) + assert result - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() + yield from hass.services.async_call( + 'conversation', 'process', { + conversation.ATTR_TEXT: 'I would like the Grolsch beer' + }) + yield from hass.async_block_till_done() - def test_custom(self): - """Setup and perform good turn on requests.""" - calls = [] + assert len(intents) == 1 + intent = intents[0] + assert intent.platform == 'conversation' + assert intent.intent_type == 'OrderBeer' + assert intent.slots == {'type': {'value': 'Grolsch'}} + assert intent.text_input == 'I would like the Grolsch beer' - @callback - def record_call(service): - """Recorder for a call.""" - calls.append(service) - self.hass.services.register('input_boolean', 'toggle', record_call) +@asyncio.coroutine +def test_register_before_setup(hass): + """Test calling an intent from a conversation.""" + intents = async_mock_intent(hass, 'OrderBeer') - event_data = {conversation.ATTR_TEXT: 'switch boolean'} - self.assertTrue(self.hass.services.call( - conversation.DOMAIN, 'process', event_data, True)) + hass.components.conversation.async_register('OrderBeer', [ + 'A {type} beer, please' + ]) - call = calls[-1] - self.assertEqual('input_boolean', call.domain) - self.assertEqual('toggle', call.service) + result = yield from async_setup_component(hass, 'conversation', { + 'conversation': { + 'intents': { + 'OrderBeer': [ + 'I would like the {type} beer' + ] + } + } + }) + assert result + + yield from hass.services.async_call( + 'conversation', 'process', { + conversation.ATTR_TEXT: 'A Grolsch beer, please' + }) + yield from hass.async_block_till_done() + + assert len(intents) == 1 + intent = intents[0] + assert intent.platform == 'conversation' + assert intent.intent_type == 'OrderBeer' + assert intent.slots == {'type': {'value': 'Grolsch'}} + assert intent.text_input == 'A Grolsch beer, please' + + yield from hass.services.async_call( + 'conversation', 'process', { + conversation.ATTR_TEXT: 'I would like the Grolsch beer' + }) + yield from hass.async_block_till_done() + + assert len(intents) == 2 + intent = intents[1] + assert intent.platform == 'conversation' + assert intent.intent_type == 'OrderBeer' + assert intent.slots == {'type': {'value': 'Grolsch'}} + assert intent.text_input == 'I would like the Grolsch beer' + + +@asyncio.coroutine +def test_http_processing_intent(hass, test_client): + """Test processing intent via HTTP API.""" + class TestIntentHandler(intent.IntentHandler): + intent_type = 'OrderBeer' + + @asyncio.coroutine + def async_handle(self, intent): + """Handle the intent.""" + response = intent.create_response() + response.async_set_speech( + "I've ordered a {}!".format(intent.slots['type']['value'])) + response.async_set_card( + "Beer ordered", + "You chose a {}.".format(intent.slots['type']['value'])) + return response + + intent.async_register(hass, TestIntentHandler()) + + result = yield from async_setup_component(hass, 'conversation', { + 'conversation': { + 'intents': { + 'OrderBeer': [ + 'I would like the {type} beer' + ] + } + } + }) + assert result + + client = yield from test_client(hass.http.app) + resp = yield from client.post('/api/conversation/process', json={ + 'text': 'I would like the Grolsch beer' + }) + + assert resp.status == 200 + data = yield from resp.json() + + assert data == { + 'card': { + 'simple': { + 'content': 'You chose a Grolsch.', + 'title': 'Beer ordered' + }}, + 'speech': { + 'plain': { + 'extra_data': None, + 'speech': "I've ordered a Grolsch!" + } + } + } diff --git a/tests/components/test_demo.py b/tests/components/test_demo.py index 1bb39b053a5..93aac65ecb5 100644 --- a/tests/components/test_demo.py +++ b/tests/components/test_demo.py @@ -1,52 +1,62 @@ """The tests for the Demo component.""" +import asyncio import json import os -import unittest -from homeassistant.setup import setup_component +import pytest + +from homeassistant.setup import async_setup_component from homeassistant.components import demo, device_tracker from homeassistant.remote import JSONEncoder -from tests.common import mock_http_component, get_test_home_assistant + +@pytest.fixture +def minimize_demo_platforms(hass): + """Cleanup demo component for tests.""" + orig = demo.COMPONENTS_WITH_DEMO_PLATFORM + demo.COMPONENTS_WITH_DEMO_PLATFORM = [ + 'switch', 'light', 'media_player'] + + yield + + demo.COMPONENTS_WITH_DEMO_PLATFORM = orig -class TestDemo(unittest.TestCase): - """Test the Demo component.""" +@pytest.fixture(autouse=True) +def demo_cleanup(hass): + """Clean up device tracker demo file.""" + yield + try: + os.remove(hass.config.path(device_tracker.YAML_DEVICES)) + except FileNotFoundError: + pass - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_http_component(self.hass) - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() +@asyncio.coroutine +def test_if_demo_state_shows_by_default(hass, minimize_demo_platforms): + """Test if demo state shows if we give no configuration.""" + yield from async_setup_component(hass, demo.DOMAIN, {demo.DOMAIN: {}}) - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass + assert hass.states.get('a.Demo_Mode') is not None - def test_if_demo_state_shows_by_default(self): - """Test if demo state shows if we give no configuration.""" - setup_component(self.hass, demo.DOMAIN, {demo.DOMAIN: {}}) - self.assertIsNotNone(self.hass.states.get('a.Demo_Mode')) +@asyncio.coroutine +def test_hiding_demo_state(hass, minimize_demo_platforms): + """Test if you can hide the demo card.""" + yield from async_setup_component(hass, demo.DOMAIN, { + demo.DOMAIN: {'hide_demo_state': 1}}) - def test_hiding_demo_state(self): - """Test if you can hide the demo card.""" - setup_component(self.hass, demo.DOMAIN, { - demo.DOMAIN: {'hide_demo_state': 1}}) + assert hass.states.get('a.Demo_Mode') is None - self.assertIsNone(self.hass.states.get('a.Demo_Mode')) - def test_all_entities_can_be_loaded_over_json(self): - """Test if you can hide the demo card.""" - setup_component(self.hass, demo.DOMAIN, { - demo.DOMAIN: {'hide_demo_state': 1}}) +@asyncio.coroutine +def test_all_entities_can_be_loaded_over_json(hass): + """Test if you can hide the demo card.""" + yield from async_setup_component(hass, demo.DOMAIN, { + demo.DOMAIN: {'hide_demo_state': 1}}) - try: - json.dumps(self.hass.states.all(), cls=JSONEncoder) - except Exception: - self.fail('Unable to convert all demo entities to JSON. ' - 'Wrong data in state machine!') + try: + json.dumps(hass.states.async_all(), cls=JSONEncoder) + except Exception: + pytest.fail('Unable to convert all demo entities to JSON. ' + 'Wrong data in state machine!') diff --git a/tests/components/test_intent_script.py b/tests/components/test_intent_script.py new file mode 100644 index 00000000000..2c7a03e645a --- /dev/null +++ b/tests/components/test_intent_script.py @@ -0,0 +1,45 @@ +"""Test intent_script component.""" +import asyncio + +from homeassistant.bootstrap import async_setup_component +from homeassistant.helpers import intent + +from tests.common import async_mock_service + + +@asyncio.coroutine +def test_intent_script(hass): + """Test intent scripts work.""" + calls = async_mock_service(hass, 'test', 'service') + + yield from async_setup_component(hass, 'intent_script', { + 'intent_script': { + 'HelloWorld': { + 'action': { + 'service': 'test.service', + 'data_template': { + 'hello': '{{ name }}' + } + }, + 'card': { + 'title': 'Hello {{ name }}', + 'content': 'Content for {{ name }}', + }, + 'speech': { + 'text': 'Good morning {{ name }}' + } + } + } + }) + + response = yield from intent.async_handle( + hass, 'test', 'HelloWorld', {'name': {'value': 'Paulus'}} + ) + + assert len(calls) == 1 + assert calls[0].data['hello'] == 'Paulus' + + assert response.speech['plain']['speech'] == 'Good morning Paulus' + + assert response.card['simple']['title'] == 'Hello Paulus' + assert response.card['simple']['content'] == 'Content for Paulus' diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py new file mode 100644 index 00000000000..867e695e2d5 --- /dev/null +++ b/tests/components/test_shopping_list.py @@ -0,0 +1,61 @@ +"""Test shopping list component.""" +import asyncio + +from homeassistant.bootstrap import async_setup_component +from homeassistant.helpers import intent + + +@asyncio.coroutine +def test_add_item(hass): + """Test adding an item intent.""" + yield from async_setup_component(hass, 'shopping_list', {}) + + response = yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + + assert response.speech['plain']['speech'] == \ + "I've added beer to your shopping list" + + +@asyncio.coroutine +def test_recent_items_intent(hass): + """Test recent items.""" + yield from async_setup_component(hass, 'shopping_list', {}) + + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} + ) + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'soda'}} + ) + + response = yield from intent.async_handle( + hass, 'test', 'HassShoppingListLastItems' + ) + + assert response.speech['plain']['speech'] == \ + "These are the top 5 items in your shopping list: soda, wine, beer" + + +@asyncio.coroutine +def test_api(hass, test_client): + """Test the API.""" + yield from async_setup_component(hass, 'shopping_list', {}) + + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} + ) + + client = yield from test_client(hass.http.app) + resp = yield from client.get('/api/shopping_list') + + assert resp.status == 200 + data = yield from resp.json() + assert data == ['beer', 'wine'] diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index 5687723e17a..5e49bbd0382 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -2,7 +2,7 @@ import asyncio from homeassistant.bootstrap import async_setup_component -from tests.common import async_fire_mqtt_message, async_mock_service +from tests.common import async_fire_mqtt_message, async_mock_intent EXAMPLE_MSG = """ { @@ -16,7 +16,7 @@ EXAMPLE_MSG = """ "slotName": "light_color", "value": { "kind": "Custom", - "value": "blue" + "value": "green" } } ] @@ -27,27 +27,19 @@ EXAMPLE_MSG = """ @asyncio.coroutine def test_snips_call_action(hass, mqtt_mock): """Test calling action via Snips.""" - calls = async_mock_service(hass, 'test', 'service') - result = yield from async_setup_component(hass, "snips", { - "snips": { - "intents": { - "Lights": { - "action": { - "service": "test.service", - "data_template": { - "color": "{{ light_color }}" - } - } - } - } - } + "snips": {}, }) assert result + intents = async_mock_intent(hass, 'Lights') + async_fire_mqtt_message(hass, 'hermes/nlu/intentParsed', EXAMPLE_MSG) yield from hass.async_block_till_done() - assert len(calls) == 1 - call = calls[0] - assert call.data.get('color') == 'blue' + assert len(intents) == 1 + intent = intents[0] + assert intent.platform == 'snips' + assert intent.intent_type == 'Lights' + assert intent.slots == {'light_color': {'value': 'green'}} + assert intent.text_input == 'turn the lights green'