mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 00:37:53 +00:00
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
@ -15,8 +15,8 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import HTTP_BAD_REQUEST
|
||||
from homeassistant.helpers import template, script, config_validation as cv
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.helpers import intent, template, config_validation as cv
|
||||
from homeassistant.components import http
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -60,6 +60,12 @@ class SpeechType(enum.Enum):
|
||||
ssml = "SSML"
|
||||
|
||||
|
||||
SPEECH_MAPPINGS = {
|
||||
'plain': SpeechType.plaintext,
|
||||
'ssml': SpeechType.ssml,
|
||||
}
|
||||
|
||||
|
||||
class CardType(enum.Enum):
|
||||
"""The Alexa card types."""
|
||||
|
||||
@ -69,20 +75,6 @@ class CardType(enum.Enum):
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: {
|
||||
CONF_INTENTS: {
|
||||
cv.string: {
|
||||
vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA,
|
||||
vol.Optional(CONF_CARD): {
|
||||
vol.Required(CONF_TYPE): cv.enum(CardType),
|
||||
vol.Required(CONF_TITLE): cv.template,
|
||||
vol.Required(CONF_CONTENT): cv.template,
|
||||
},
|
||||
vol.Optional(CONF_SPEECH): {
|
||||
vol.Required(CONF_TYPE): cv.enum(SpeechType),
|
||||
vol.Required(CONF_TEXT): cv.template,
|
||||
}
|
||||
}
|
||||
},
|
||||
CONF_FLASH_BRIEFINGS: {
|
||||
cv.string: vol.All(cv.ensure_list, [{
|
||||
vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string,
|
||||
@ -96,40 +88,27 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Activate Alexa component."""
|
||||
intents = config[DOMAIN].get(CONF_INTENTS, {})
|
||||
flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {})
|
||||
|
||||
hass.http.register_view(AlexaIntentsView(hass, intents))
|
||||
hass.http.register_view(AlexaIntentsView)
|
||||
hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class AlexaIntentsView(HomeAssistantView):
|
||||
class AlexaIntentsView(http.HomeAssistantView):
|
||||
"""Handle Alexa requests."""
|
||||
|
||||
url = INTENTS_API_ENDPOINT
|
||||
name = 'api:alexa'
|
||||
|
||||
def __init__(self, hass, intents):
|
||||
"""Initialize Alexa view."""
|
||||
super().__init__()
|
||||
|
||||
intents = copy.deepcopy(intents)
|
||||
template.attach(hass, intents)
|
||||
|
||||
for name, intent in intents.items():
|
||||
if CONF_ACTION in intent:
|
||||
intent[CONF_ACTION] = script.Script(
|
||||
hass, intent[CONF_ACTION], "Alexa intent {}".format(name))
|
||||
|
||||
self.intents = intents
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Handle Alexa."""
|
||||
hass = request.app['hass']
|
||||
data = yield from request.json()
|
||||
|
||||
_LOGGER.debug('Received Alexa request: %s', data)
|
||||
@ -146,14 +125,14 @@ class AlexaIntentsView(HomeAssistantView):
|
||||
if req_type == 'SessionEndedRequest':
|
||||
return None
|
||||
|
||||
intent = req.get('intent')
|
||||
response = AlexaResponse(request.app['hass'], intent)
|
||||
alexa_intent_info = req.get('intent')
|
||||
alexa_response = AlexaResponse(hass, alexa_intent_info)
|
||||
|
||||
if req_type == 'LaunchRequest':
|
||||
response.add_speech(
|
||||
alexa_response.add_speech(
|
||||
SpeechType.plaintext,
|
||||
"Hello, and welcome to the future. How may I help?")
|
||||
return self.json(response)
|
||||
return self.json(alexa_response)
|
||||
|
||||
if req_type != 'IntentRequest':
|
||||
_LOGGER.warning('Received unsupported request: %s', req_type)
|
||||
@ -161,38 +140,47 @@ class AlexaIntentsView(HomeAssistantView):
|
||||
'Received unsupported request: {}'.format(req_type),
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
intent_name = intent['name']
|
||||
config = self.intents.get(intent_name)
|
||||
intent_name = alexa_intent_info['name']
|
||||
|
||||
if config is None:
|
||||
try:
|
||||
intent_response = yield from intent.async_handle(
|
||||
hass, DOMAIN, intent_name,
|
||||
{key: {'value': value} for key, value
|
||||
in alexa_response.variables.items()})
|
||||
except intent.UnknownIntent as err:
|
||||
_LOGGER.warning('Received unknown intent %s', intent_name)
|
||||
response.add_speech(
|
||||
alexa_response.add_speech(
|
||||
SpeechType.plaintext,
|
||||
"This intent is not yet configured within Home Assistant.")
|
||||
return self.json(response)
|
||||
return self.json(alexa_response)
|
||||
|
||||
speech = config.get(CONF_SPEECH)
|
||||
card = config.get(CONF_CARD)
|
||||
action = config.get(CONF_ACTION)
|
||||
except intent.InvalidSlotInfo as err:
|
||||
_LOGGER.error('Received invalid slot data from Alexa: %s', err)
|
||||
return self.json_message('Invalid slot data received',
|
||||
HTTP_BAD_REQUEST)
|
||||
except intent.IntentError:
|
||||
_LOGGER.exception('Error handling request for %s', intent_name)
|
||||
return self.json_message('Error handling intent', HTTP_BAD_REQUEST)
|
||||
|
||||
if action is not None:
|
||||
yield from action.async_run(response.variables)
|
||||
for intent_speech, alexa_speech in SPEECH_MAPPINGS.items():
|
||||
if intent_speech in intent_response.speech:
|
||||
alexa_response.add_speech(
|
||||
alexa_speech,
|
||||
intent_response.speech[intent_speech]['speech'])
|
||||
break
|
||||
|
||||
# pylint: disable=unsubscriptable-object
|
||||
if speech is not None:
|
||||
response.add_speech(speech[CONF_TYPE], speech[CONF_TEXT])
|
||||
if 'simple' in intent_response.card:
|
||||
alexa_response.add_card(
|
||||
'simple', intent_response.card['simple']['title'],
|
||||
intent_response.card['simple']['content'])
|
||||
|
||||
if card is not None:
|
||||
response.add_card(card[CONF_TYPE], card[CONF_TITLE],
|
||||
card[CONF_CONTENT])
|
||||
|
||||
return self.json(response)
|
||||
return self.json(alexa_response)
|
||||
|
||||
|
||||
class AlexaResponse(object):
|
||||
"""Help generating the response for Alexa."""
|
||||
|
||||
def __init__(self, hass, intent=None):
|
||||
def __init__(self, hass, intent_info):
|
||||
"""Initialize the response."""
|
||||
self.hass = hass
|
||||
self.speech = None
|
||||
@ -201,8 +189,9 @@ class AlexaResponse(object):
|
||||
self.session_attributes = {}
|
||||
self.should_end_session = True
|
||||
self.variables = {}
|
||||
if intent is not None and 'slots' in intent:
|
||||
for key, value in intent['slots'].items():
|
||||
# Intent is None if request was a LaunchRequest or SessionEndedRequest
|
||||
if intent_info is not None:
|
||||
for key, value in intent_info.get('slots', {}).items():
|
||||
if 'value' in value:
|
||||
underscored_key = key.replace('.', '_')
|
||||
self.variables[underscored_key] = value['value']
|
||||
@ -272,7 +261,7 @@ class AlexaResponse(object):
|
||||
}
|
||||
|
||||
|
||||
class AlexaFlashBriefingView(HomeAssistantView):
|
||||
class AlexaFlashBriefingView(http.HomeAssistantView):
|
||||
"""Handle Alexa Flash Briefing skill requests."""
|
||||
|
||||
url = FLASH_BRIEFINGS_API_ENDPOINT
|
||||
|
@ -5,13 +5,12 @@ For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/apiai/
|
||||
"""
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import PROJECT_NAME, HTTP_BAD_REQUEST
|
||||
from homeassistant.helpers import template, script, config_validation as cv
|
||||
from homeassistant.helpers import intent, template
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -29,24 +28,14 @@ DOMAIN = 'apiai'
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: {
|
||||
CONF_INTENTS: {
|
||||
cv.string: {
|
||||
vol.Optional(CONF_SPEECH): cv.template,
|
||||
vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA,
|
||||
vol.Optional(CONF_ASYNC_ACTION,
|
||||
default=DEFAULT_CONF_ASYNC_ACTION): cv.boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
DOMAIN: {}
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Activate API.AI component."""
|
||||
intents = config[DOMAIN].get(CONF_INTENTS, {})
|
||||
|
||||
hass.http.register_view(ApiaiIntentsView(hass, intents))
|
||||
hass.http.register_view(ApiaiIntentsView)
|
||||
|
||||
return True
|
||||
|
||||
@ -57,24 +46,10 @@ class ApiaiIntentsView(HomeAssistantView):
|
||||
url = INTENTS_API_ENDPOINT
|
||||
name = 'api:apiai'
|
||||
|
||||
def __init__(self, hass, intents):
|
||||
"""Initialize API.AI view."""
|
||||
super().__init__()
|
||||
|
||||
self.hass = hass
|
||||
intents = copy.deepcopy(intents)
|
||||
template.attach(hass, intents)
|
||||
|
||||
for name, intent in intents.items():
|
||||
if CONF_ACTION in intent:
|
||||
intent[CONF_ACTION] = script.Script(
|
||||
hass, intent[CONF_ACTION], "Apiai intent {}".format(name))
|
||||
|
||||
self.intents = intents
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Handle API.AI."""
|
||||
hass = request.app['hass']
|
||||
data = yield from request.json()
|
||||
|
||||
_LOGGER.debug("Received api.ai request: %s", data)
|
||||
@ -91,55 +66,41 @@ class ApiaiIntentsView(HomeAssistantView):
|
||||
if action_incomplete:
|
||||
return None
|
||||
|
||||
# use intent to no mix HASS actions with this parameter
|
||||
intent = req.get('action')
|
||||
action = req.get('action')
|
||||
parameters = req.get('parameters')
|
||||
# contexts = req.get('contexts')
|
||||
response = ApiaiResponse(parameters)
|
||||
apiai_response = ApiaiResponse(parameters)
|
||||
|
||||
# Default Welcome Intent
|
||||
# Maybe is better to handle this in api.ai directly?
|
||||
#
|
||||
# if intent == 'input.welcome':
|
||||
# response.add_speech(
|
||||
# "Hello, and welcome to the future. How may I help?")
|
||||
# return self.json(response)
|
||||
|
||||
if intent == "":
|
||||
if action == "":
|
||||
_LOGGER.warning("Received intent with empty action")
|
||||
response.add_speech(
|
||||
apiai_response.add_speech(
|
||||
"You have not defined an action in your api.ai intent.")
|
||||
return self.json(response)
|
||||
return self.json(apiai_response)
|
||||
|
||||
config = self.intents.get(intent)
|
||||
try:
|
||||
intent_response = yield from intent.async_handle(
|
||||
hass, DOMAIN, action,
|
||||
{key: {'value': value} for key, value
|
||||
in parameters.items()})
|
||||
|
||||
if config is None:
|
||||
_LOGGER.warning("Received unknown intent %s", intent)
|
||||
response.add_speech(
|
||||
"Intent '%s' is not yet configured within Home Assistant." %
|
||||
intent)
|
||||
return self.json(response)
|
||||
except intent.UnknownIntent as err:
|
||||
_LOGGER.warning('Received unknown intent %s', action)
|
||||
apiai_response.add_speech(
|
||||
"This intent is not yet configured within Home Assistant.")
|
||||
return self.json(apiai_response)
|
||||
|
||||
speech = config.get(CONF_SPEECH)
|
||||
action = config.get(CONF_ACTION)
|
||||
async_action = config.get(CONF_ASYNC_ACTION)
|
||||
except intent.InvalidSlotInfo as err:
|
||||
_LOGGER.error('Received invalid slot data: %s', err)
|
||||
return self.json_message('Invalid slot data received',
|
||||
HTTP_BAD_REQUEST)
|
||||
except intent.IntentError:
|
||||
_LOGGER.exception('Error handling request for %s', action)
|
||||
return self.json_message('Error handling intent', HTTP_BAD_REQUEST)
|
||||
|
||||
if action is not None:
|
||||
# API.AI expects a response in less than 5s
|
||||
if async_action:
|
||||
# Do not wait for the action to be executed.
|
||||
# Needed if the action will take longer than 5s to execute
|
||||
self.hass.async_add_job(action.async_run(response.parameters))
|
||||
else:
|
||||
# Wait for the action to be executed so we can use results to
|
||||
# render the answer
|
||||
yield from action.async_run(response.parameters)
|
||||
if 'plain' in intent_response.speech:
|
||||
apiai_response.add_speech(
|
||||
intent_response.speech['plain']['speech'])
|
||||
|
||||
# pylint: disable=unsubscriptable-object
|
||||
if speech is not None:
|
||||
response.add_speech(speech)
|
||||
|
||||
return self.json(response)
|
||||
return self.json(apiai_response)
|
||||
|
||||
|
||||
class ApiaiResponse(object):
|
||||
|
@ -4,6 +4,7 @@ Support for functionality to have conversations with Home Assistant.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/conversation/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import warnings
|
||||
@ -11,16 +12,17 @@ import warnings
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import core
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers import script
|
||||
ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, HTTP_BAD_REQUEST)
|
||||
from homeassistant.helpers import intent, config_validation as cv
|
||||
from homeassistant.components import http
|
||||
|
||||
|
||||
REQUIREMENTS = ['fuzzywuzzy==0.15.0']
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
ATTR_TEXT = 'text'
|
||||
ATTR_SENTENCE = 'sentence'
|
||||
DOMAIN = 'conversation'
|
||||
|
||||
REGEX_TURN_COMMAND = re.compile(r'turn (?P<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_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_TEXT): vol.All(cv.string, vol.Lower),
|
||||
vol.Required(ATTR_TEXT): cv.string,
|
||||
})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({
|
||||
cv.string: vol.Schema({
|
||||
vol.Required(ATTR_SENTENCE): cv.string,
|
||||
vol.Required('action'): cv.SCRIPT_SCHEMA,
|
||||
vol.Optional('intents'): vol.Schema({
|
||||
cv.string: vol.All(cv.ensure_list, [cv.string])
|
||||
})
|
||||
})}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def setup(hass, config):
|
||||
|
||||
@core.callback
|
||||
@bind_hass
|
||||
def async_register(hass, intent_type, utterances):
|
||||
"""Register an intent.
|
||||
|
||||
Registrations don't require conversations to be loaded. They will become
|
||||
active once the conversation component is loaded.
|
||||
"""
|
||||
intents = hass.data.get(DOMAIN)
|
||||
|
||||
if intents is None:
|
||||
intents = hass.data[DOMAIN] = {}
|
||||
|
||||
conf = intents.get(intent_type)
|
||||
|
||||
if conf is None:
|
||||
conf = intents[intent_type] = []
|
||||
|
||||
conf.extend(_create_matcher(utterance) for utterance in utterances)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Register the process service."""
|
||||
warnings.filterwarnings('ignore', module='fuzzywuzzy')
|
||||
from fuzzywuzzy import process as fuzzyExtract
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
config = config.get(DOMAIN, {})
|
||||
intents = hass.data.get(DOMAIN)
|
||||
|
||||
choices = {attrs[ATTR_SENTENCE]: script.Script(
|
||||
hass,
|
||||
attrs['action'],
|
||||
name)
|
||||
for name, attrs in config.items()}
|
||||
if intents is None:
|
||||
intents = hass.data[DOMAIN] = {}
|
||||
|
||||
for intent_type, utterances in config.get('intents', {}).items():
|
||||
conf = intents.get(intent_type)
|
||||
|
||||
if conf is None:
|
||||
conf = intents[intent_type] = []
|
||||
|
||||
conf.extend(_create_matcher(utterance) for utterance in utterances)
|
||||
|
||||
@asyncio.coroutine
|
||||
def process(service):
|
||||
"""Parse text into commands."""
|
||||
# if actually configured
|
||||
if choices:
|
||||
text = service.data[ATTR_TEXT]
|
||||
match = fuzzyExtract.extractOne(text, choices.keys())
|
||||
scorelimit = 60 # arbitrary value
|
||||
logging.info(
|
||||
'matched up text %s and found %s',
|
||||
text,
|
||||
[match[0] if match[1] > scorelimit else 'nothing']
|
||||
)
|
||||
if match[1] > scorelimit:
|
||||
choices[match[0]].run() # run respective script
|
||||
return
|
||||
|
||||
text = service.data[ATTR_TEXT]
|
||||
match = REGEX_TURN_COMMAND.match(text)
|
||||
yield from _process(hass, text)
|
||||
|
||||
if not match:
|
||||
logger.error("Unable to process: %s", text)
|
||||
return
|
||||
|
||||
name, command = match.groups()
|
||||
entities = {state.entity_id: state.name for state in hass.states.all()}
|
||||
entity_ids = fuzzyExtract.extractOne(
|
||||
name, entities, score_cutoff=65)[2]
|
||||
|
||||
if not entity_ids:
|
||||
logger.error(
|
||||
"Could not find entity id %s from text %s", name, text)
|
||||
return
|
||||
|
||||
if command == 'on':
|
||||
hass.services.call(core.DOMAIN, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity_ids,
|
||||
}, blocking=True)
|
||||
|
||||
elif command == 'off':
|
||||
hass.services.call(core.DOMAIN, SERVICE_TURN_OFF, {
|
||||
ATTR_ENTITY_ID: entity_ids,
|
||||
}, blocking=True)
|
||||
|
||||
else:
|
||||
logger.error('Got unsupported command %s from text %s',
|
||||
command, text)
|
||||
|
||||
hass.services.register(
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA)
|
||||
|
||||
hass.http.register_view(ConversationProcessView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _create_matcher(utterance):
|
||||
"""Create a regex that matches the utterance."""
|
||||
parts = re.split(r'({\w+})', utterance)
|
||||
group_matcher = re.compile(r'{(\w+)}')
|
||||
|
||||
pattern = ['^']
|
||||
|
||||
for part in parts:
|
||||
match = group_matcher.match(part)
|
||||
|
||||
if match is None:
|
||||
pattern.append(part)
|
||||
continue
|
||||
|
||||
pattern.append('(?P<{}>{})'.format(match.groups()[0], r'[\w ]+'))
|
||||
|
||||
pattern.append('$')
|
||||
return re.compile(''.join(pattern), re.I)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def _process(hass, text):
|
||||
"""Process a line of text."""
|
||||
intents = hass.data.get(DOMAIN, {})
|
||||
|
||||
for intent_type, matchers in intents.items():
|
||||
for matcher in matchers:
|
||||
match = matcher.match(text)
|
||||
|
||||
if not match:
|
||||
continue
|
||||
|
||||
response = yield from intent.async_handle(
|
||||
hass, DOMAIN, intent_type,
|
||||
{key: {'value': value} for key, value
|
||||
in match.groupdict().items()}, text)
|
||||
return response
|
||||
|
||||
from fuzzywuzzy import process as fuzzyExtract
|
||||
text = text.lower()
|
||||
match = REGEX_TURN_COMMAND.match(text)
|
||||
|
||||
if not match:
|
||||
_LOGGER.error("Unable to process: %s", text)
|
||||
return None
|
||||
|
||||
name, command = match.groups()
|
||||
entities = {state.entity_id: state.name for state
|
||||
in hass.states.async_all()}
|
||||
entity_ids = fuzzyExtract.extractOne(
|
||||
name, entities, score_cutoff=65)[2]
|
||||
|
||||
if not entity_ids:
|
||||
_LOGGER.error(
|
||||
"Could not find entity id %s from text %s", name, text)
|
||||
return
|
||||
|
||||
if command == 'on':
|
||||
yield from hass.services.async_call(
|
||||
core.DOMAIN, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity_ids,
|
||||
}, blocking=True)
|
||||
|
||||
elif command == 'off':
|
||||
yield from hass.services.async_call(
|
||||
core.DOMAIN, SERVICE_TURN_OFF, {
|
||||
ATTR_ENTITY_ID: entity_ids,
|
||||
}, blocking=True)
|
||||
|
||||
else:
|
||||
_LOGGER.error('Got unsupported command %s from text %s',
|
||||
command, text)
|
||||
|
||||
|
||||
class ConversationProcessView(http.HomeAssistantView):
|
||||
"""View to retrieve shopping list content."""
|
||||
|
||||
url = '/api/conversation/process'
|
||||
name = "api:conversation:process"
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Send a request for processing."""
|
||||
hass = request.app['hass']
|
||||
try:
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
return self.json_message('Invalid JSON specified',
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
text = data.get('text')
|
||||
|
||||
if text is None:
|
||||
return self.json_message('Missing "text" key in JSON.',
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
intent_result = yield from _process(hass, text)
|
||||
|
||||
return self.json(intent_result)
|
||||
|
@ -12,6 +12,7 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.config import find_config_file, load_yaml_config_file
|
||||
from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.components import api
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.http.auth import is_trusted_ip
|
||||
@ -75,6 +76,7 @@ SERVICE_SET_THEME_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
@bind_hass
|
||||
def register_built_in_panel(hass, component_name, sidebar_title=None,
|
||||
sidebar_icon=None, url_path=None, config=None):
|
||||
"""Register a built-in panel."""
|
||||
@ -96,6 +98,7 @@ def register_built_in_panel(hass, component_name, sidebar_title=None,
|
||||
sidebar_icon, url_path, url, config)
|
||||
|
||||
|
||||
@bind_hass
|
||||
def register_panel(hass, component_name, path, md5=None, sidebar_title=None,
|
||||
sidebar_icon=None, url_path=None, url=None, config=None):
|
||||
"""Register a panel for the frontend.
|
||||
|
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/
|
||||
"""
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
from homeassistant.helpers import template, script, config_validation as cv
|
||||
import homeassistant.loader as loader
|
||||
from homeassistant.helpers import intent, config_validation as cv
|
||||
|
||||
DOMAIN = 'snips'
|
||||
DEPENDENCIES = ['mqtt']
|
||||
@ -19,16 +17,10 @@ CONF_ACTION = 'action'
|
||||
|
||||
INTENT_TOPIC = 'hermes/nlu/intentParsed'
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: {
|
||||
CONF_INTENTS: {
|
||||
cv.string: {
|
||||
vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA,
|
||||
}
|
||||
}
|
||||
}
|
||||
DOMAIN: {}
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
INTENT_SCHEMA = vol.Schema({
|
||||
@ -49,74 +41,34 @@ INTENT_SCHEMA = vol.Schema({
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Activate Snips component."""
|
||||
mqtt = loader.get_component('mqtt')
|
||||
intents = config[DOMAIN].get(CONF_INTENTS, {})
|
||||
handler = IntentHandler(hass, intents)
|
||||
|
||||
@asyncio.coroutine
|
||||
def message_received(topic, payload, qos):
|
||||
"""Handle new messages on MQTT."""
|
||||
LOGGER.debug("New intent: %s", payload)
|
||||
yield from handler.handle_intent(payload)
|
||||
_LOGGER.debug("New intent: %s", payload)
|
||||
|
||||
yield from mqtt.async_subscribe(hass, INTENT_TOPIC, message_received)
|
||||
try:
|
||||
request = json.loads(payload)
|
||||
except TypeError:
|
||||
_LOGGER.error('Received invalid JSON: %s', payload)
|
||||
return
|
||||
|
||||
try:
|
||||
request = INTENT_SCHEMA(request)
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error('Intent has invalid schema: %s. %s', err, request)
|
||||
return
|
||||
|
||||
intent_type = request['intent']['intentName'].split('__')[-1]
|
||||
slots = {slot['slotName']: {'value': slot['value']['value']}
|
||||
for slot in request.get('slots', [])}
|
||||
|
||||
try:
|
||||
yield from intent.async_handle(
|
||||
hass, DOMAIN, intent_type, slots, request['input'])
|
||||
except intent.IntentError:
|
||||
_LOGGER.exception("Error while handling intent.")
|
||||
|
||||
yield from hass.components.mqtt.async_subscribe(
|
||||
INTENT_TOPIC, message_received)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class IntentHandler(object):
|
||||
"""Help handling intents."""
|
||||
|
||||
def __init__(self, hass, intents):
|
||||
"""Initialize the intent handler."""
|
||||
self.hass = hass
|
||||
intents = copy.deepcopy(intents)
|
||||
template.attach(hass, intents)
|
||||
|
||||
for name, intent in intents.items():
|
||||
if CONF_ACTION in intent:
|
||||
intent[CONF_ACTION] = script.Script(
|
||||
hass, intent[CONF_ACTION], "Snips intent {}".format(name))
|
||||
|
||||
self.intents = intents
|
||||
|
||||
@asyncio.coroutine
|
||||
def handle_intent(self, payload):
|
||||
"""Handle an intent."""
|
||||
try:
|
||||
response = json.loads(payload)
|
||||
except TypeError:
|
||||
LOGGER.error('Received invalid JSON: %s', payload)
|
||||
return
|
||||
|
||||
try:
|
||||
response = INTENT_SCHEMA(response)
|
||||
except vol.Invalid as err:
|
||||
LOGGER.error('Intent has invalid schema: %s. %s', err, response)
|
||||
return
|
||||
|
||||
intent = response['intent']['intentName'].split('__')[-1]
|
||||
config = self.intents.get(intent)
|
||||
|
||||
if config is None:
|
||||
LOGGER.warning("Received unknown intent %s. %s", intent, response)
|
||||
return
|
||||
|
||||
action = config.get(CONF_ACTION)
|
||||
|
||||
if action is not None:
|
||||
slots = self.parse_slots(response)
|
||||
yield from action.async_run(slots)
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def parse_slots(self, response):
|
||||
"""Parse the intent slots."""
|
||||
parameters = {}
|
||||
|
||||
for slot in response.get('slots', []):
|
||||
key = slot['slotName']
|
||||
value = slot['value']['value']
|
||||
if value is not None:
|
||||
parameters[key] = value
|
||||
|
||||
return parameters
|
||||
|
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.setup import setup_component, async_setup_component
|
||||
from homeassistant.config import async_process_component_config
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE
|
||||
from homeassistant.helpers import intent, dispatcher, entity, restore_state
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
import homeassistant.util.dt as date_util
|
||||
import homeassistant.util.yaml as yaml
|
||||
@ -193,12 +191,31 @@ def async_mock_service(hass, domain, service):
|
||||
mock_service = threadsafe_callback_factory(async_mock_service)
|
||||
|
||||
|
||||
@ha.callback
|
||||
def async_mock_intent(hass, intent_typ):
|
||||
"""Set up a fake intent handler."""
|
||||
intents = []
|
||||
|
||||
class MockIntentHandler(intent.IntentHandler):
|
||||
intent_type = intent_typ
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle(self, intent):
|
||||
"""Handle the intent."""
|
||||
intents.append(intent)
|
||||
return intent.create_response()
|
||||
|
||||
intent.async_register(hass, MockIntentHandler())
|
||||
|
||||
return intents
|
||||
|
||||
|
||||
@ha.callback
|
||||
def async_fire_mqtt_message(hass, topic, payload, qos=0):
|
||||
"""Fire the MQTT message."""
|
||||
if isinstance(payload, str):
|
||||
payload = payload.encode('utf-8')
|
||||
async_dispatcher_send(
|
||||
dispatcher.async_dispatcher_send(
|
||||
hass, mqtt.SIGNAL_MQTT_MESSAGE_RECEIVED, topic,
|
||||
payload, qos)
|
||||
|
||||
@ -352,7 +369,7 @@ class MockPlatform(object):
|
||||
self._setup_platform(hass, config, add_devices, discovery_info)
|
||||
|
||||
|
||||
class MockToggleDevice(ToggleEntity):
|
||||
class MockToggleDevice(entity.ToggleEntity):
|
||||
"""Provide a mock toggle device."""
|
||||
|
||||
def __init__(self, name, state):
|
||||
@ -506,10 +523,11 @@ def init_recorder_component(hass, add_config=None):
|
||||
|
||||
def mock_restore_cache(hass, states):
|
||||
"""Mock the DATA_RESTORE_CACHE."""
|
||||
hass.data[DATA_RESTORE_CACHE] = {
|
||||
key = restore_state.DATA_RESTORE_CACHE
|
||||
hass.data[key] = {
|
||||
state.entity_id: state for state in states}
|
||||
_LOGGER.debug('Restore cache: %s', hass.data[DATA_RESTORE_CACHE])
|
||||
assert len(hass.data[DATA_RESTORE_CACHE]) == len(states), \
|
||||
_LOGGER.debug('Restore cache: %s', hass.data[key])
|
||||
assert len(hass.data[key]) == len(states), \
|
||||
"Duplicate entity_id? {}".format(states)
|
||||
hass.state = ha.CoreState.starting
|
||||
mock_component(hass, recorder.DOMAIN)
|
||||
|
@ -47,10 +47,14 @@ def alexa_client(loop, hass, test_client):
|
||||
"uid": "uuid"
|
||||
}
|
||||
},
|
||||
"intents": {
|
||||
}
|
||||
}))
|
||||
assert loop.run_until_complete(async_setup_component(
|
||||
hass, 'intent_script', {
|
||||
'intent_script': {
|
||||
"WhereAreWeIntent": {
|
||||
"speech": {
|
||||
"type": "plaintext",
|
||||
"type": "plain",
|
||||
"text":
|
||||
"""
|
||||
{%- if is_state("device_tracker.paulus", "home")
|
||||
@ -69,19 +73,19 @@ def alexa_client(loop, hass, test_client):
|
||||
},
|
||||
"GetZodiacHoroscopeIntent": {
|
||||
"speech": {
|
||||
"type": "plaintext",
|
||||
"type": "plain",
|
||||
"text": "You told us your sign is {{ ZodiacSign }}.",
|
||||
}
|
||||
},
|
||||
"AMAZON.PlaybackAction<object@MusicCreativeWork>": {
|
||||
"speech": {
|
||||
"type": "plaintext",
|
||||
"type": "plain",
|
||||
"text": "Playing {{ object_byArtist_name }}.",
|
||||
}
|
||||
},
|
||||
"CallServiceIntent": {
|
||||
"speech": {
|
||||
"type": "plaintext",
|
||||
"type": "plain",
|
||||
"text": "Service called",
|
||||
},
|
||||
"action": {
|
||||
@ -93,8 +97,7 @@ def alexa_client(loop, hass, test_client):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}))
|
||||
return loop.run_until_complete(test_client(hass.http.app))
|
||||
|
||||
|
||||
|
@ -57,14 +57,15 @@ def setUpModule():
|
||||
|
||||
hass.services.register("test", "apiai", mock_service)
|
||||
|
||||
setup.setup_component(hass, apiai.DOMAIN, {
|
||||
# Key is here to verify we allow other keys in config too
|
||||
"homeassistant": {},
|
||||
"apiai": {
|
||||
"intents": {
|
||||
"WhereAreWeIntent": {
|
||||
"speech":
|
||||
"""
|
||||
assert setup.setup_component(hass, apiai.DOMAIN, {
|
||||
"apiai": {},
|
||||
})
|
||||
assert setup.setup_component(hass, "intent_script", {
|
||||
"intent_script": {
|
||||
"WhereAreWeIntent": {
|
||||
"speech": {
|
||||
"type": "plain",
|
||||
"text": """
|
||||
{%- if is_state("device_tracker.paulus", "home")
|
||||
and is_state("device_tracker.anne_therese",
|
||||
"home") -%}
|
||||
@ -77,19 +78,25 @@ def setUpModule():
|
||||
}}
|
||||
{% endif %}
|
||||
""",
|
||||
}
|
||||
},
|
||||
"GetZodiacHoroscopeIntent": {
|
||||
"speech": {
|
||||
"type": "plain",
|
||||
"text": "You told us your sign is {{ ZodiacSign }}.",
|
||||
}
|
||||
},
|
||||
"CallServiceIntent": {
|
||||
"speech": {
|
||||
"type": "plain",
|
||||
"text": "Service called",
|
||||
},
|
||||
"GetZodiacHoroscopeIntent": {
|
||||
"speech": "You told us your sign is {{ ZodiacSign }}.",
|
||||
},
|
||||
"CallServiceIntent": {
|
||||
"speech": "Service called",
|
||||
"action": {
|
||||
"service": "test.apiai",
|
||||
"data_template": {
|
||||
"hello": "{{ ZodiacSign }}"
|
||||
},
|
||||
"entity_id": "switch.test",
|
||||
}
|
||||
"action": {
|
||||
"service": "test.apiai",
|
||||
"data_template": {
|
||||
"hello": "{{ ZodiacSign }}"
|
||||
},
|
||||
"entity_id": "switch.test",
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -509,5 +516,4 @@ class TestApiai(unittest.TestCase):
|
||||
self.assertEqual(200, req.status_code)
|
||||
text = req.json().get("speech")
|
||||
self.assertEqual(
|
||||
"Intent 'unknown' is not yet configured within Home Assistant.",
|
||||
text)
|
||||
"This intent is not yet configured within Home Assistant.", text)
|
||||
|
@ -1,16 +1,18 @@
|
||||
"""The tests for the Conversation component."""
|
||||
# pylint: disable=protected-access
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.setup import setup_component
|
||||
from homeassistant.setup import setup_component, async_setup_component
|
||||
import homeassistant.components as core_components
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.util.async import run_coroutine_threadsafe
|
||||
from homeassistant.helpers import intent
|
||||
|
||||
from tests.common import get_test_home_assistant, assert_setup_component
|
||||
from tests.common import get_test_home_assistant, async_mock_intent
|
||||
|
||||
|
||||
class TestConversation(unittest.TestCase):
|
||||
@ -25,10 +27,9 @@ class TestConversation(unittest.TestCase):
|
||||
self.assertTrue(run_coroutine_threadsafe(
|
||||
core_components.async_setup(self.hass, {}), self.hass.loop
|
||||
).result())
|
||||
with assert_setup_component(0):
|
||||
self.assertTrue(setup_component(self.hass, conversation.DOMAIN, {
|
||||
conversation.DOMAIN: {}
|
||||
}))
|
||||
self.assertTrue(setup_component(self.hass, conversation.DOMAIN, {
|
||||
conversation.DOMAIN: {}
|
||||
}))
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def tearDown(self):
|
||||
@ -119,44 +120,131 @@ class TestConversation(unittest.TestCase):
|
||||
self.assertFalse(mock_call.called)
|
||||
|
||||
|
||||
class TestConfiguration(unittest.TestCase):
|
||||
"""Test the conversation configuration component."""
|
||||
@asyncio.coroutine
|
||||
def test_calling_intent(hass):
|
||||
"""Test calling an intent from a conversation."""
|
||||
intents = async_mock_intent(hass, 'OrderBeer')
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def setUp(self):
|
||||
"""Setup things to be run when tests are started."""
|
||||
self.hass = get_test_home_assistant()
|
||||
self.assertTrue(setup_component(self.hass, conversation.DOMAIN, {
|
||||
conversation.DOMAIN: {
|
||||
'test_2': {
|
||||
'sentence': 'switch boolean',
|
||||
'action': {
|
||||
'service': 'input_boolean.toggle'
|
||||
}
|
||||
}
|
||||
result = yield from async_setup_component(hass, 'conversation', {
|
||||
'conversation': {
|
||||
'intents': {
|
||||
'OrderBeer': [
|
||||
'I would like the {type} beer'
|
||||
]
|
||||
}
|
||||
}))
|
||||
}
|
||||
})
|
||||
assert result
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def tearDown(self):
|
||||
"""Stop everything that was started."""
|
||||
self.hass.stop()
|
||||
yield from hass.services.async_call(
|
||||
'conversation', 'process', {
|
||||
conversation.ATTR_TEXT: 'I would like the Grolsch beer'
|
||||
})
|
||||
yield from hass.async_block_till_done()
|
||||
|
||||
def test_custom(self):
|
||||
"""Setup and perform good turn on requests."""
|
||||
calls = []
|
||||
assert len(intents) == 1
|
||||
intent = intents[0]
|
||||
assert intent.platform == 'conversation'
|
||||
assert intent.intent_type == 'OrderBeer'
|
||||
assert intent.slots == {'type': {'value': 'Grolsch'}}
|
||||
assert intent.text_input == 'I would like the Grolsch beer'
|
||||
|
||||
@callback
|
||||
def record_call(service):
|
||||
"""Recorder for a call."""
|
||||
calls.append(service)
|
||||
|
||||
self.hass.services.register('input_boolean', 'toggle', record_call)
|
||||
@asyncio.coroutine
|
||||
def test_register_before_setup(hass):
|
||||
"""Test calling an intent from a conversation."""
|
||||
intents = async_mock_intent(hass, 'OrderBeer')
|
||||
|
||||
event_data = {conversation.ATTR_TEXT: 'switch boolean'}
|
||||
self.assertTrue(self.hass.services.call(
|
||||
conversation.DOMAIN, 'process', event_data, True))
|
||||
hass.components.conversation.async_register('OrderBeer', [
|
||||
'A {type} beer, please'
|
||||
])
|
||||
|
||||
call = calls[-1]
|
||||
self.assertEqual('input_boolean', call.domain)
|
||||
self.assertEqual('toggle', call.service)
|
||||
result = yield from async_setup_component(hass, 'conversation', {
|
||||
'conversation': {
|
||||
'intents': {
|
||||
'OrderBeer': [
|
||||
'I would like the {type} beer'
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
assert result
|
||||
|
||||
yield from hass.services.async_call(
|
||||
'conversation', 'process', {
|
||||
conversation.ATTR_TEXT: 'A Grolsch beer, please'
|
||||
})
|
||||
yield from hass.async_block_till_done()
|
||||
|
||||
assert len(intents) == 1
|
||||
intent = intents[0]
|
||||
assert intent.platform == 'conversation'
|
||||
assert intent.intent_type == 'OrderBeer'
|
||||
assert intent.slots == {'type': {'value': 'Grolsch'}}
|
||||
assert intent.text_input == 'A Grolsch beer, please'
|
||||
|
||||
yield from hass.services.async_call(
|
||||
'conversation', 'process', {
|
||||
conversation.ATTR_TEXT: 'I would like the Grolsch beer'
|
||||
})
|
||||
yield from hass.async_block_till_done()
|
||||
|
||||
assert len(intents) == 2
|
||||
intent = intents[1]
|
||||
assert intent.platform == 'conversation'
|
||||
assert intent.intent_type == 'OrderBeer'
|
||||
assert intent.slots == {'type': {'value': 'Grolsch'}}
|
||||
assert intent.text_input == 'I would like the Grolsch beer'
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_http_processing_intent(hass, test_client):
|
||||
"""Test processing intent via HTTP API."""
|
||||
class TestIntentHandler(intent.IntentHandler):
|
||||
intent_type = 'OrderBeer'
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle(self, intent):
|
||||
"""Handle the intent."""
|
||||
response = intent.create_response()
|
||||
response.async_set_speech(
|
||||
"I've ordered a {}!".format(intent.slots['type']['value']))
|
||||
response.async_set_card(
|
||||
"Beer ordered",
|
||||
"You chose a {}.".format(intent.slots['type']['value']))
|
||||
return response
|
||||
|
||||
intent.async_register(hass, TestIntentHandler())
|
||||
|
||||
result = yield from async_setup_component(hass, 'conversation', {
|
||||
'conversation': {
|
||||
'intents': {
|
||||
'OrderBeer': [
|
||||
'I would like the {type} beer'
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
assert result
|
||||
|
||||
client = yield from test_client(hass.http.app)
|
||||
resp = yield from client.post('/api/conversation/process', json={
|
||||
'text': 'I would like the Grolsch beer'
|
||||
})
|
||||
|
||||
assert resp.status == 200
|
||||
data = yield from resp.json()
|
||||
|
||||
assert data == {
|
||||
'card': {
|
||||
'simple': {
|
||||
'content': 'You chose a Grolsch.',
|
||||
'title': 'Beer ordered'
|
||||
}},
|
||||
'speech': {
|
||||
'plain': {
|
||||
'extra_data': None,
|
||||
'speech': "I've ordered a Grolsch!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,52 +1,62 @@
|
||||
"""The tests for the Demo component."""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from homeassistant.setup import setup_component
|
||||
import pytest
|
||||
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.components import demo, device_tracker
|
||||
from homeassistant.remote import JSONEncoder
|
||||
|
||||
from tests.common import mock_http_component, get_test_home_assistant
|
||||
|
||||
@pytest.fixture
|
||||
def minimize_demo_platforms(hass):
|
||||
"""Cleanup demo component for tests."""
|
||||
orig = demo.COMPONENTS_WITH_DEMO_PLATFORM
|
||||
demo.COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
'switch', 'light', 'media_player']
|
||||
|
||||
yield
|
||||
|
||||
demo.COMPONENTS_WITH_DEMO_PLATFORM = orig
|
||||
|
||||
|
||||
class TestDemo(unittest.TestCase):
|
||||
"""Test the Demo component."""
|
||||
@pytest.fixture(autouse=True)
|
||||
def demo_cleanup(hass):
|
||||
"""Clean up device tracker demo file."""
|
||||
yield
|
||||
try:
|
||||
os.remove(hass.config.path(device_tracker.YAML_DEVICES))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def setUp(self): # pylint: disable=invalid-name
|
||||
"""Setup things to be run when tests are started."""
|
||||
self.hass = get_test_home_assistant()
|
||||
mock_http_component(self.hass)
|
||||
|
||||
def tearDown(self): # pylint: disable=invalid-name
|
||||
"""Stop everything that was started."""
|
||||
self.hass.stop()
|
||||
@asyncio.coroutine
|
||||
def test_if_demo_state_shows_by_default(hass, minimize_demo_platforms):
|
||||
"""Test if demo state shows if we give no configuration."""
|
||||
yield from async_setup_component(hass, demo.DOMAIN, {demo.DOMAIN: {}})
|
||||
|
||||
try:
|
||||
os.remove(self.hass.config.path(device_tracker.YAML_DEVICES))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
assert hass.states.get('a.Demo_Mode') is not None
|
||||
|
||||
def test_if_demo_state_shows_by_default(self):
|
||||
"""Test if demo state shows if we give no configuration."""
|
||||
setup_component(self.hass, demo.DOMAIN, {demo.DOMAIN: {}})
|
||||
|
||||
self.assertIsNotNone(self.hass.states.get('a.Demo_Mode'))
|
||||
@asyncio.coroutine
|
||||
def test_hiding_demo_state(hass, minimize_demo_platforms):
|
||||
"""Test if you can hide the demo card."""
|
||||
yield from async_setup_component(hass, demo.DOMAIN, {
|
||||
demo.DOMAIN: {'hide_demo_state': 1}})
|
||||
|
||||
def test_hiding_demo_state(self):
|
||||
"""Test if you can hide the demo card."""
|
||||
setup_component(self.hass, demo.DOMAIN, {
|
||||
demo.DOMAIN: {'hide_demo_state': 1}})
|
||||
assert hass.states.get('a.Demo_Mode') is None
|
||||
|
||||
self.assertIsNone(self.hass.states.get('a.Demo_Mode'))
|
||||
|
||||
def test_all_entities_can_be_loaded_over_json(self):
|
||||
"""Test if you can hide the demo card."""
|
||||
setup_component(self.hass, demo.DOMAIN, {
|
||||
demo.DOMAIN: {'hide_demo_state': 1}})
|
||||
@asyncio.coroutine
|
||||
def test_all_entities_can_be_loaded_over_json(hass):
|
||||
"""Test if you can hide the demo card."""
|
||||
yield from async_setup_component(hass, demo.DOMAIN, {
|
||||
demo.DOMAIN: {'hide_demo_state': 1}})
|
||||
|
||||
try:
|
||||
json.dumps(self.hass.states.all(), cls=JSONEncoder)
|
||||
except Exception:
|
||||
self.fail('Unable to convert all demo entities to JSON. '
|
||||
'Wrong data in state machine!')
|
||||
try:
|
||||
json.dumps(hass.states.async_all(), cls=JSONEncoder)
|
||||
except Exception:
|
||||
pytest.fail('Unable to convert all demo entities to JSON. '
|
||||
'Wrong data in state machine!')
|
||||
|
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
|
||||
|
||||
from homeassistant.bootstrap import async_setup_component
|
||||
from tests.common import async_fire_mqtt_message, async_mock_service
|
||||
from tests.common import async_fire_mqtt_message, async_mock_intent
|
||||
|
||||
EXAMPLE_MSG = """
|
||||
{
|
||||
@ -16,7 +16,7 @@ EXAMPLE_MSG = """
|
||||
"slotName": "light_color",
|
||||
"value": {
|
||||
"kind": "Custom",
|
||||
"value": "blue"
|
||||
"value": "green"
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -27,27 +27,19 @@ EXAMPLE_MSG = """
|
||||
@asyncio.coroutine
|
||||
def test_snips_call_action(hass, mqtt_mock):
|
||||
"""Test calling action via Snips."""
|
||||
calls = async_mock_service(hass, 'test', 'service')
|
||||
|
||||
result = yield from async_setup_component(hass, "snips", {
|
||||
"snips": {
|
||||
"intents": {
|
||||
"Lights": {
|
||||
"action": {
|
||||
"service": "test.service",
|
||||
"data_template": {
|
||||
"color": "{{ light_color }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"snips": {},
|
||||
})
|
||||
assert result
|
||||
|
||||
intents = async_mock_intent(hass, 'Lights')
|
||||
|
||||
async_fire_mqtt_message(hass, 'hermes/nlu/intentParsed',
|
||||
EXAMPLE_MSG)
|
||||
yield from hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call.data.get('color') == 'blue'
|
||||
assert len(intents) == 1
|
||||
intent = intents[0]
|
||||
assert intent.platform == 'snips'
|
||||
assert intent.intent_type == 'Lights'
|
||||
assert intent.slots == {'light_color': {'value': 'green'}}
|
||||
assert intent.text_input == 'turn the lights green'
|
||||
|
Loading…
x
Reference in New Issue
Block a user