Add Intent component (#8434)

* Add intent component

* Add intent script component

* Add shopping list component

* Convert Snips to use intent component

* Convert Alexa to use intent component

* Lint

* Fix Alexa tests

* Update snips test

* Add intent support to conversation

* Add API to view shopping list contents

* Lint

* Fix demo test

* Lint

* lint

* Remove type from slot schema

* Add dependency to conversation

* Move intent to be a helper

* Fix conversation

* Clean up intent helper

* Fix Alexa

* Snips to use new hass.components

* Allow registering intents with conversation at any point in time

* Shopping list to register sentences

* Add HTTP endpoint to Conversation

* Add async action option to intent_script

* Update API.ai to use intents

* Cleanup Alexa

* Shopping list component to register built-in panel

* Rename shopping list intent to inlude Hass name
This commit is contained in:
Paulus Schoutsen 2017-07-21 21:38:53 -07:00 committed by GitHub
parent 7bea69ce83
commit 7edf14e55f
16 changed files with 970 additions and 396 deletions

View file

@ -15,8 +15,8 @@ import voluptuous as vol
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.const import HTTP_BAD_REQUEST
from homeassistant.helpers import template, script, config_validation as cv from homeassistant.helpers import intent, template, config_validation as cv
from homeassistant.components.http import HomeAssistantView from homeassistant.components import http
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -60,6 +60,12 @@ class SpeechType(enum.Enum):
ssml = "SSML" ssml = "SSML"
SPEECH_MAPPINGS = {
'plain': SpeechType.plaintext,
'ssml': SpeechType.ssml,
}
class CardType(enum.Enum): class CardType(enum.Enum):
"""The Alexa card types.""" """The Alexa card types."""
@ -69,20 +75,6 @@ class CardType(enum.Enum):
CONFIG_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({
DOMAIN: { 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: { CONF_FLASH_BRIEFINGS: {
cv.string: vol.All(cv.ensure_list, [{ cv.string: vol.All(cv.ensure_list, [{
vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string, vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string,
@ -96,40 +88,27 @@ CONFIG_SCHEMA = vol.Schema({
}, extra=vol.ALLOW_EXTRA) }, extra=vol.ALLOW_EXTRA)
def setup(hass, config): @asyncio.coroutine
def async_setup(hass, config):
"""Activate Alexa component.""" """Activate Alexa component."""
intents = config[DOMAIN].get(CONF_INTENTS, {})
flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {}) 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)) hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings))
return True return True
class AlexaIntentsView(HomeAssistantView): class AlexaIntentsView(http.HomeAssistantView):
"""Handle Alexa requests.""" """Handle Alexa requests."""
url = INTENTS_API_ENDPOINT url = INTENTS_API_ENDPOINT
name = 'api:alexa' 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 @asyncio.coroutine
def post(self, request): def post(self, request):
"""Handle Alexa.""" """Handle Alexa."""
hass = request.app['hass']
data = yield from request.json() data = yield from request.json()
_LOGGER.debug('Received Alexa request: %s', data) _LOGGER.debug('Received Alexa request: %s', data)
@ -146,14 +125,14 @@ class AlexaIntentsView(HomeAssistantView):
if req_type == 'SessionEndedRequest': if req_type == 'SessionEndedRequest':
return None return None
intent = req.get('intent') alexa_intent_info = req.get('intent')
response = AlexaResponse(request.app['hass'], intent) alexa_response = AlexaResponse(hass, alexa_intent_info)
if req_type == 'LaunchRequest': if req_type == 'LaunchRequest':
response.add_speech( alexa_response.add_speech(
SpeechType.plaintext, SpeechType.plaintext,
"Hello, and welcome to the future. How may I help?") "Hello, and welcome to the future. How may I help?")
return self.json(response) return self.json(alexa_response)
if req_type != 'IntentRequest': if req_type != 'IntentRequest':
_LOGGER.warning('Received unsupported request: %s', req_type) _LOGGER.warning('Received unsupported request: %s', req_type)
@ -161,38 +140,47 @@ class AlexaIntentsView(HomeAssistantView):
'Received unsupported request: {}'.format(req_type), 'Received unsupported request: {}'.format(req_type),
HTTP_BAD_REQUEST) HTTP_BAD_REQUEST)
intent_name = intent['name'] intent_name = alexa_intent_info['name']
config = self.intents.get(intent_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) _LOGGER.warning('Received unknown intent %s', intent_name)
response.add_speech( alexa_response.add_speech(
SpeechType.plaintext, SpeechType.plaintext,
"This intent is not yet configured within Home Assistant.") "This intent is not yet configured within Home Assistant.")
return self.json(response) return self.json(alexa_response)
speech = config.get(CONF_SPEECH) except intent.InvalidSlotInfo as err:
card = config.get(CONF_CARD) _LOGGER.error('Received invalid slot data from Alexa: %s', err)
action = config.get(CONF_ACTION) 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: for intent_speech, alexa_speech in SPEECH_MAPPINGS.items():
yield from action.async_run(response.variables) 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 'simple' in intent_response.card:
if speech is not None: alexa_response.add_card(
response.add_speech(speech[CONF_TYPE], speech[CONF_TEXT]) 'simple', intent_response.card['simple']['title'],
intent_response.card['simple']['content'])
if card is not None: return self.json(alexa_response)
response.add_card(card[CONF_TYPE], card[CONF_TITLE],
card[CONF_CONTENT])
return self.json(response)
class AlexaResponse(object): class AlexaResponse(object):
"""Help generating the response for Alexa.""" """Help generating the response for Alexa."""
def __init__(self, hass, intent=None): def __init__(self, hass, intent_info):
"""Initialize the response.""" """Initialize the response."""
self.hass = hass self.hass = hass
self.speech = None self.speech = None
@ -201,8 +189,9 @@ class AlexaResponse(object):
self.session_attributes = {} self.session_attributes = {}
self.should_end_session = True self.should_end_session = True
self.variables = {} self.variables = {}
if intent is not None and 'slots' in intent: # Intent is None if request was a LaunchRequest or SessionEndedRequest
for key, value in intent['slots'].items(): if intent_info is not None:
for key, value in intent_info.get('slots', {}).items():
if 'value' in value: if 'value' in value:
underscored_key = key.replace('.', '_') underscored_key = key.replace('.', '_')
self.variables[underscored_key] = value['value'] 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.""" """Handle Alexa Flash Briefing skill requests."""
url = FLASH_BRIEFINGS_API_ENDPOINT url = FLASH_BRIEFINGS_API_ENDPOINT

View file

@ -5,13 +5,12 @@ For more details about this component, please refer to the documentation at
https://home-assistant.io/components/apiai/ https://home-assistant.io/components/apiai/
""" """
import asyncio import asyncio
import copy
import logging import logging
import voluptuous as vol import voluptuous as vol
from homeassistant.const import PROJECT_NAME, HTTP_BAD_REQUEST 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 from homeassistant.components.http import HomeAssistantView
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -29,24 +28,14 @@ DOMAIN = 'apiai'
DEPENDENCIES = ['http'] DEPENDENCIES = ['http']
CONFIG_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({
DOMAIN: { 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
}
}
}
}, extra=vol.ALLOW_EXTRA) }, extra=vol.ALLOW_EXTRA)
def setup(hass, config): @asyncio.coroutine
def async_setup(hass, config):
"""Activate API.AI component.""" """Activate API.AI component."""
intents = config[DOMAIN].get(CONF_INTENTS, {}) hass.http.register_view(ApiaiIntentsView)
hass.http.register_view(ApiaiIntentsView(hass, intents))
return True return True
@ -57,24 +46,10 @@ class ApiaiIntentsView(HomeAssistantView):
url = INTENTS_API_ENDPOINT url = INTENTS_API_ENDPOINT
name = 'api:apiai' 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 @asyncio.coroutine
def post(self, request): def post(self, request):
"""Handle API.AI.""" """Handle API.AI."""
hass = request.app['hass']
data = yield from request.json() data = yield from request.json()
_LOGGER.debug("Received api.ai request: %s", data) _LOGGER.debug("Received api.ai request: %s", data)
@ -91,55 +66,41 @@ class ApiaiIntentsView(HomeAssistantView):
if action_incomplete: if action_incomplete:
return None return None
# use intent to no mix HASS actions with this parameter action = req.get('action')
intent = req.get('action')
parameters = req.get('parameters') parameters = req.get('parameters')
# contexts = req.get('contexts') apiai_response = ApiaiResponse(parameters)
response = ApiaiResponse(parameters)
# Default Welcome Intent if action == "":
# 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 == "":
_LOGGER.warning("Received intent with empty 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.") "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: except intent.UnknownIntent as err:
_LOGGER.warning("Received unknown intent %s", intent) _LOGGER.warning('Received unknown intent %s', action)
response.add_speech( apiai_response.add_speech(
"Intent '%s' is not yet configured within Home Assistant." % "This intent is not yet configured within Home Assistant.")
intent) return self.json(apiai_response)
return self.json(response)
speech = config.get(CONF_SPEECH) except intent.InvalidSlotInfo as err:
action = config.get(CONF_ACTION) _LOGGER.error('Received invalid slot data: %s', err)
async_action = config.get(CONF_ASYNC_ACTION) 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: if 'plain' in intent_response.speech:
# API.AI expects a response in less than 5s apiai_response.add_speech(
if async_action: intent_response.speech['plain']['speech'])
# 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)
# pylint: disable=unsubscriptable-object return self.json(apiai_response)
if speech is not None:
response.add_speech(speech)
return self.json(response)
class ApiaiResponse(object): class ApiaiResponse(object):

View file

@ -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 For more details about this component, please refer to the documentation at
https://home-assistant.io/components/conversation/ https://home-assistant.io/components/conversation/
""" """
import asyncio
import logging import logging
import re import re
import warnings import warnings
@ -11,16 +12,17 @@ import warnings
import voluptuous as vol import voluptuous as vol
from homeassistant import core from homeassistant import core
from homeassistant.loader import bind_hass
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON) ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, HTTP_BAD_REQUEST)
import homeassistant.helpers.config_validation as cv from homeassistant.helpers import intent, config_validation as cv
from homeassistant.helpers import script from homeassistant.components import http
REQUIREMENTS = ['fuzzywuzzy==0.15.0'] REQUIREMENTS = ['fuzzywuzzy==0.15.0']
DEPENDENCIES = ['http']
ATTR_TEXT = 'text' ATTR_TEXT = 'text'
ATTR_SENTENCE = 'sentence'
DOMAIN = 'conversation' DOMAIN = 'conversation'
REGEX_TURN_COMMAND = re.compile(r'turn (?P<name>(?: |\w)+) (?P<command>\w+)') REGEX_TURN_COMMAND = re.compile(r'turn (?P<name>(?: |\w)+) (?P<command>\w+)')
@ -28,79 +30,168 @@ REGEX_TURN_COMMAND = re.compile(r'turn (?P<name>(?: |\w)+) (?P<command>\w+)')
SERVICE_PROCESS = 'process' SERVICE_PROCESS = 'process'
SERVICE_PROCESS_SCHEMA = vol.Schema({ 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({ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({
cv.string: vol.Schema({ vol.Optional('intents'): vol.Schema({
vol.Required(ATTR_SENTENCE): cv.string, cv.string: vol.All(cv.ensure_list, [cv.string])
vol.Required('action'): cv.SCRIPT_SCHEMA,
}) })
})}, extra=vol.ALLOW_EXTRA) })}, 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.""" """Register the process service."""
warnings.filterwarnings('ignore', module='fuzzywuzzy') warnings.filterwarnings('ignore', module='fuzzywuzzy')
from fuzzywuzzy import process as fuzzyExtract
logger = logging.getLogger(__name__)
config = config.get(DOMAIN, {}) config = config.get(DOMAIN, {})
intents = hass.data.get(DOMAIN)
choices = {attrs[ATTR_SENTENCE]: script.Script( if intents is None:
hass, intents = hass.data[DOMAIN] = {}
attrs['action'],
name)
for name, attrs in config.items()}
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): def process(service):
"""Parse text into commands.""" """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] text = service.data[ATTR_TEXT]
match = REGEX_TURN_COMMAND.match(text) yield from _process(hass, text)
if not match: hass.services.async_register(
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(
DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA) DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA)
hass.http.register_view(ConversationProcessView)
return True 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)

View file

@ -12,6 +12,7 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.config import find_config_file, load_yaml_config_file from homeassistant.config import find_config_file, load_yaml_config_file
from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.loader import bind_hass
from homeassistant.components import api from homeassistant.components import api
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.auth import is_trusted_ip 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, def register_built_in_panel(hass, component_name, sidebar_title=None,
sidebar_icon=None, url_path=None, config=None): sidebar_icon=None, url_path=None, config=None):
"""Register a built-in panel.""" """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) sidebar_icon, url_path, url, config)
@bind_hass
def register_panel(hass, component_name, path, md5=None, sidebar_title=None, def register_panel(hass, component_name, path, md5=None, sidebar_title=None,
sidebar_icon=None, url_path=None, url=None, config=None): sidebar_icon=None, url_path=None, url=None, config=None):
"""Register a panel for the frontend. """Register a panel for the frontend.

View file

@ -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

View file

@ -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])

View file

@ -5,12 +5,10 @@ For more details about this component, please refer to the documentation at
https://home-assistant.io/components/snips/ https://home-assistant.io/components/snips/
""" """
import asyncio import asyncio
import copy
import json import json
import logging import logging
import voluptuous as vol import voluptuous as vol
from homeassistant.helpers import template, script, config_validation as cv from homeassistant.helpers import intent, config_validation as cv
import homeassistant.loader as loader
DOMAIN = 'snips' DOMAIN = 'snips'
DEPENDENCIES = ['mqtt'] DEPENDENCIES = ['mqtt']
@ -19,16 +17,10 @@ CONF_ACTION = 'action'
INTENT_TOPIC = 'hermes/nlu/intentParsed' INTENT_TOPIC = 'hermes/nlu/intentParsed'
LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({
DOMAIN: { DOMAIN: {}
CONF_INTENTS: {
cv.string: {
vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA,
}
}
}
}, extra=vol.ALLOW_EXTRA) }, extra=vol.ALLOW_EXTRA)
INTENT_SCHEMA = vol.Schema({ INTENT_SCHEMA = vol.Schema({
@ -49,74 +41,34 @@ INTENT_SCHEMA = vol.Schema({
@asyncio.coroutine @asyncio.coroutine
def async_setup(hass, config): def async_setup(hass, config):
"""Activate Snips component.""" """Activate Snips component."""
mqtt = loader.get_component('mqtt')
intents = config[DOMAIN].get(CONF_INTENTS, {})
handler = IntentHandler(hass, intents)
@asyncio.coroutine @asyncio.coroutine
def message_received(topic, payload, qos): def message_received(topic, payload, qos):
"""Handle new messages on MQTT.""" """Handle new messages on MQTT."""
LOGGER.debug("New intent: %s", payload) _LOGGER.debug("New intent: %s", payload)
yield from handler.handle_intent(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 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

View file

@ -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,
}

View file

@ -14,9 +14,7 @@ from aiohttp import web
from homeassistant import core as ha, loader from homeassistant import core as ha, loader
from homeassistant.setup import setup_component, async_setup_component from homeassistant.setup import setup_component, async_setup_component
from homeassistant.config import async_process_component_config from homeassistant.config import async_process_component_config
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers import intent, dispatcher, entity, restore_state
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE
from homeassistant.util.unit_system import METRIC_SYSTEM from homeassistant.util.unit_system import METRIC_SYSTEM
import homeassistant.util.dt as date_util import homeassistant.util.dt as date_util
import homeassistant.util.yaml as yaml 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) 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 @ha.callback
def async_fire_mqtt_message(hass, topic, payload, qos=0): def async_fire_mqtt_message(hass, topic, payload, qos=0):
"""Fire the MQTT message.""" """Fire the MQTT message."""
if isinstance(payload, str): if isinstance(payload, str):
payload = payload.encode('utf-8') payload = payload.encode('utf-8')
async_dispatcher_send( dispatcher.async_dispatcher_send(
hass, mqtt.SIGNAL_MQTT_MESSAGE_RECEIVED, topic, hass, mqtt.SIGNAL_MQTT_MESSAGE_RECEIVED, topic,
payload, qos) payload, qos)
@ -352,7 +369,7 @@ class MockPlatform(object):
self._setup_platform(hass, config, add_devices, discovery_info) self._setup_platform(hass, config, add_devices, discovery_info)
class MockToggleDevice(ToggleEntity): class MockToggleDevice(entity.ToggleEntity):
"""Provide a mock toggle device.""" """Provide a mock toggle device."""
def __init__(self, name, state): def __init__(self, name, state):
@ -506,10 +523,11 @@ def init_recorder_component(hass, add_config=None):
def mock_restore_cache(hass, states): def mock_restore_cache(hass, states):
"""Mock the DATA_RESTORE_CACHE.""" """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} state.entity_id: state for state in states}
_LOGGER.debug('Restore cache: %s', hass.data[DATA_RESTORE_CACHE]) _LOGGER.debug('Restore cache: %s', hass.data[key])
assert len(hass.data[DATA_RESTORE_CACHE]) == len(states), \ assert len(hass.data[key]) == len(states), \
"Duplicate entity_id? {}".format(states) "Duplicate entity_id? {}".format(states)
hass.state = ha.CoreState.starting hass.state = ha.CoreState.starting
mock_component(hass, recorder.DOMAIN) mock_component(hass, recorder.DOMAIN)

View file

@ -47,10 +47,14 @@ def alexa_client(loop, hass, test_client):
"uid": "uuid" "uid": "uuid"
} }
}, },
"intents": { }
}))
assert loop.run_until_complete(async_setup_component(
hass, 'intent_script', {
'intent_script': {
"WhereAreWeIntent": { "WhereAreWeIntent": {
"speech": { "speech": {
"type": "plaintext", "type": "plain",
"text": "text":
""" """
{%- if is_state("device_tracker.paulus", "home") {%- if is_state("device_tracker.paulus", "home")
@ -69,19 +73,19 @@ def alexa_client(loop, hass, test_client):
}, },
"GetZodiacHoroscopeIntent": { "GetZodiacHoroscopeIntent": {
"speech": { "speech": {
"type": "plaintext", "type": "plain",
"text": "You told us your sign is {{ ZodiacSign }}.", "text": "You told us your sign is {{ ZodiacSign }}.",
} }
}, },
"AMAZON.PlaybackAction<object@MusicCreativeWork>": { "AMAZON.PlaybackAction<object@MusicCreativeWork>": {
"speech": { "speech": {
"type": "plaintext", "type": "plain",
"text": "Playing {{ object_byArtist_name }}.", "text": "Playing {{ object_byArtist_name }}.",
} }
}, },
"CallServiceIntent": { "CallServiceIntent": {
"speech": { "speech": {
"type": "plaintext", "type": "plain",
"text": "Service called", "text": "Service called",
}, },
"action": { "action": {
@ -93,8 +97,7 @@ def alexa_client(loop, hass, test_client):
} }
} }
} }
} }))
}))
return loop.run_until_complete(test_client(hass.http.app)) return loop.run_until_complete(test_client(hass.http.app))

View file

@ -57,14 +57,15 @@ def setUpModule():
hass.services.register("test", "apiai", mock_service) hass.services.register("test", "apiai", mock_service)
setup.setup_component(hass, apiai.DOMAIN, { assert setup.setup_component(hass, apiai.DOMAIN, {
# Key is here to verify we allow other keys in config too "apiai": {},
"homeassistant": {}, })
"apiai": { assert setup.setup_component(hass, "intent_script", {
"intents": { "intent_script": {
"WhereAreWeIntent": { "WhereAreWeIntent": {
"speech": "speech": {
""" "type": "plain",
"text": """
{%- if is_state("device_tracker.paulus", "home") {%- if is_state("device_tracker.paulus", "home")
and is_state("device_tracker.anne_therese", and is_state("device_tracker.anne_therese",
"home") -%} "home") -%}
@ -77,19 +78,25 @@ def setUpModule():
}} }}
{% endif %} {% endif %}
""", """,
}
},
"GetZodiacHoroscopeIntent": {
"speech": {
"type": "plain",
"text": "You told us your sign is {{ ZodiacSign }}.",
}
},
"CallServiceIntent": {
"speech": {
"type": "plain",
"text": "Service called",
}, },
"GetZodiacHoroscopeIntent": { "action": {
"speech": "You told us your sign is {{ ZodiacSign }}.", "service": "test.apiai",
}, "data_template": {
"CallServiceIntent": { "hello": "{{ ZodiacSign }}"
"speech": "Service called", },
"action": { "entity_id": "switch.test",
"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) self.assertEqual(200, req.status_code)
text = req.json().get("speech") text = req.json().get("speech")
self.assertEqual( self.assertEqual(
"Intent 'unknown' is not yet configured within Home Assistant.", "This intent is not yet configured within Home Assistant.", text)
text)

View file

@ -1,16 +1,18 @@
"""The tests for the Conversation component.""" """The tests for the Conversation component."""
# pylint: disable=protected-access # pylint: disable=protected-access
import asyncio
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from homeassistant.core import callback 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 import homeassistant.components as core_components
from homeassistant.components import conversation from homeassistant.components import conversation
from homeassistant.const import ATTR_ENTITY_ID from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.util.async import run_coroutine_threadsafe 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): class TestConversation(unittest.TestCase):
@ -25,10 +27,9 @@ class TestConversation(unittest.TestCase):
self.assertTrue(run_coroutine_threadsafe( self.assertTrue(run_coroutine_threadsafe(
core_components.async_setup(self.hass, {}), self.hass.loop core_components.async_setup(self.hass, {}), self.hass.loop
).result()) ).result())
with assert_setup_component(0): self.assertTrue(setup_component(self.hass, conversation.DOMAIN, {
self.assertTrue(setup_component(self.hass, conversation.DOMAIN, { conversation.DOMAIN: {}
conversation.DOMAIN: {} }))
}))
# pylint: disable=invalid-name # pylint: disable=invalid-name
def tearDown(self): def tearDown(self):
@ -119,44 +120,131 @@ class TestConversation(unittest.TestCase):
self.assertFalse(mock_call.called) self.assertFalse(mock_call.called)
class TestConfiguration(unittest.TestCase): @asyncio.coroutine
"""Test the conversation configuration component.""" def test_calling_intent(hass):
"""Test calling an intent from a conversation."""
intents = async_mock_intent(hass, 'OrderBeer')
# pylint: disable=invalid-name result = yield from async_setup_component(hass, 'conversation', {
def setUp(self): 'conversation': {
"""Setup things to be run when tests are started.""" 'intents': {
self.hass = get_test_home_assistant() 'OrderBeer': [
self.assertTrue(setup_component(self.hass, conversation.DOMAIN, { 'I would like the {type} beer'
conversation.DOMAIN: { ]
'test_2': {
'sentence': 'switch boolean',
'action': {
'service': 'input_boolean.toggle'
}
}
} }
})) }
})
assert result
# pylint: disable=invalid-name yield from hass.services.async_call(
def tearDown(self): 'conversation', 'process', {
"""Stop everything that was started.""" conversation.ATTR_TEXT: 'I would like the Grolsch beer'
self.hass.stop() })
yield from hass.async_block_till_done()
def test_custom(self): assert len(intents) == 1
"""Setup and perform good turn on requests.""" intent = intents[0]
calls = [] 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'} hass.components.conversation.async_register('OrderBeer', [
self.assertTrue(self.hass.services.call( 'A {type} beer, please'
conversation.DOMAIN, 'process', event_data, True)) ])
call = calls[-1] result = yield from async_setup_component(hass, 'conversation', {
self.assertEqual('input_boolean', call.domain) 'conversation': {
self.assertEqual('toggle', call.service) '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!"
}
}
}

View file

@ -1,52 +1,62 @@
"""The tests for the Demo component.""" """The tests for the Demo component."""
import asyncio
import json import json
import os 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.components import demo, device_tracker
from homeassistant.remote import JSONEncoder 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): @pytest.fixture(autouse=True)
"""Test the Demo component.""" 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 @asyncio.coroutine
"""Stop everything that was started.""" def test_if_demo_state_shows_by_default(hass, minimize_demo_platforms):
self.hass.stop() """Test if demo state shows if we give no configuration."""
yield from async_setup_component(hass, demo.DOMAIN, {demo.DOMAIN: {}})
try: assert hass.states.get('a.Demo_Mode') is not None
os.remove(self.hass.config.path(device_tracker.YAML_DEVICES))
except FileNotFoundError:
pass
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): assert hass.states.get('a.Demo_Mode') is None
"""Test if you can hide the demo card."""
setup_component(self.hass, demo.DOMAIN, {
demo.DOMAIN: {'hide_demo_state': 1}})
self.assertIsNone(self.hass.states.get('a.Demo_Mode'))
def test_all_entities_can_be_loaded_over_json(self): @asyncio.coroutine
"""Test if you can hide the demo card.""" def test_all_entities_can_be_loaded_over_json(hass):
setup_component(self.hass, demo.DOMAIN, { """Test if you can hide the demo card."""
demo.DOMAIN: {'hide_demo_state': 1}}) yield from async_setup_component(hass, demo.DOMAIN, {
demo.DOMAIN: {'hide_demo_state': 1}})
try: try:
json.dumps(self.hass.states.all(), cls=JSONEncoder) json.dumps(hass.states.async_all(), cls=JSONEncoder)
except Exception: except Exception:
self.fail('Unable to convert all demo entities to JSON. ' pytest.fail('Unable to convert all demo entities to JSON. '
'Wrong data in state machine!') 'Wrong data in state machine!')

View file

@ -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'

View file

@ -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']

View file

@ -2,7 +2,7 @@
import asyncio import asyncio
from homeassistant.bootstrap import async_setup_component 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 = """ EXAMPLE_MSG = """
{ {
@ -16,7 +16,7 @@ EXAMPLE_MSG = """
"slotName": "light_color", "slotName": "light_color",
"value": { "value": {
"kind": "Custom", "kind": "Custom",
"value": "blue" "value": "green"
} }
} }
] ]
@ -27,27 +27,19 @@ EXAMPLE_MSG = """
@asyncio.coroutine @asyncio.coroutine
def test_snips_call_action(hass, mqtt_mock): def test_snips_call_action(hass, mqtt_mock):
"""Test calling action via Snips.""" """Test calling action via Snips."""
calls = async_mock_service(hass, 'test', 'service')
result = yield from async_setup_component(hass, "snips", { result = yield from async_setup_component(hass, "snips", {
"snips": { "snips": {},
"intents": {
"Lights": {
"action": {
"service": "test.service",
"data_template": {
"color": "{{ light_color }}"
}
}
}
}
}
}) })
assert result assert result
intents = async_mock_intent(hass, 'Lights')
async_fire_mqtt_message(hass, 'hermes/nlu/intentParsed', async_fire_mqtt_message(hass, 'hermes/nlu/intentParsed',
EXAMPLE_MSG) EXAMPLE_MSG)
yield from hass.async_block_till_done() yield from hass.async_block_till_done()
assert len(calls) == 1 assert len(intents) == 1
call = calls[0] intent = intents[0]
assert call.data.get('color') == 'blue' assert intent.platform == 'snips'
assert intent.intent_type == 'Lights'
assert intent.slots == {'light_color': {'value': 'green'}}
assert intent.text_input == 'turn the lights green'