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:
parent
7bea69ce83
commit
7edf14e55f
16 changed files with 970 additions and 396 deletions
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
100
homeassistant/components/intent_script.py
Normal file
100
homeassistant/components/intent_script.py
Normal 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
|
90
homeassistant/components/shopping_list.py
Normal file
90
homeassistant/components/shopping_list.py
Normal 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])
|
|
@ -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
|
|
||||||
|
|
165
homeassistant/helpers/intent.py
Normal file
165
homeassistant/helpers/intent.py
Normal 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,
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
|
@ -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!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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!')
|
||||||
|
|
45
tests/components/test_intent_script.py
Normal file
45
tests/components/test_intent_script.py
Normal 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'
|
61
tests/components/test_shopping_list.py
Normal file
61
tests/components/test_shopping_list.py
Normal 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']
|
|
@ -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'
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue