Merge pull request #739 from balloob/alexa
Amazon Echo / Alexa support for retrieving informationpull/717/merge
commit
4528c57539
|
@ -0,0 +1,186 @@
|
||||||
|
"""
|
||||||
|
components.alexa
|
||||||
|
~~~~~~~~~~~~~~~~
|
||||||
|
Component to offer a service end point for an Alexa skill.
|
||||||
|
|
||||||
|
For more details about this component, please refer to the documentation at
|
||||||
|
https://home-assistant.io/components/alexa/
|
||||||
|
"""
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY
|
||||||
|
from homeassistant.util import template
|
||||||
|
|
||||||
|
DOMAIN = 'alexa'
|
||||||
|
DEPENDENCIES = ['http']
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
_CONFIG = {}
|
||||||
|
|
||||||
|
API_ENDPOINT = '/api/alexa'
|
||||||
|
|
||||||
|
CONF_INTENTS = 'intents'
|
||||||
|
CONF_CARD = 'card'
|
||||||
|
CONF_SPEECH = 'speech'
|
||||||
|
|
||||||
|
|
||||||
|
def setup(hass, config):
|
||||||
|
""" Activate Alexa component. """
|
||||||
|
_CONFIG.update(config[DOMAIN].get(CONF_INTENTS, {}))
|
||||||
|
|
||||||
|
hass.http.register_path('POST', API_ENDPOINT, _handle_alexa, True)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_alexa(handler, path_match, data):
|
||||||
|
""" Handle Alexa. """
|
||||||
|
_LOGGER.debug('Received Alexa request: %s', data)
|
||||||
|
|
||||||
|
req = data.get('request')
|
||||||
|
|
||||||
|
if req is None:
|
||||||
|
_LOGGER.error('Received invalid data from Alexa: %s', data)
|
||||||
|
handler.write_json_message(
|
||||||
|
"Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY)
|
||||||
|
return
|
||||||
|
|
||||||
|
req_type = req['type']
|
||||||
|
|
||||||
|
if req_type == 'SessionEndedRequest':
|
||||||
|
handler.send_response(HTTP_OK)
|
||||||
|
handler.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
intent = req.get('intent')
|
||||||
|
response = AlexaResponse(handler.server.hass, intent)
|
||||||
|
|
||||||
|
if req_type == 'LaunchRequest':
|
||||||
|
response.add_speech(
|
||||||
|
SpeechType.plaintext,
|
||||||
|
"Hello, and welcome to the future. How may I help?")
|
||||||
|
handler.write_json(response.as_dict())
|
||||||
|
return
|
||||||
|
|
||||||
|
if req_type != 'IntentRequest':
|
||||||
|
_LOGGER.warning('Received unsupported request: %s', req_type)
|
||||||
|
return
|
||||||
|
|
||||||
|
intent_name = intent['name']
|
||||||
|
config = _CONFIG.get(intent_name)
|
||||||
|
|
||||||
|
if config is None:
|
||||||
|
_LOGGER.warning('Received unknown intent %s', intent_name)
|
||||||
|
response.add_speech(
|
||||||
|
SpeechType.plaintext,
|
||||||
|
"This intent is not yet configured within Home Assistant.")
|
||||||
|
handler.write_json(response.as_dict())
|
||||||
|
return
|
||||||
|
|
||||||
|
speech = config.get(CONF_SPEECH)
|
||||||
|
card = config.get(CONF_CARD)
|
||||||
|
|
||||||
|
# pylint: disable=unsubscriptable-object
|
||||||
|
if speech is not None:
|
||||||
|
response.add_speech(SpeechType[speech['type']], speech['text'])
|
||||||
|
|
||||||
|
if card is not None:
|
||||||
|
response.add_card(CardType[card['type']], card['title'],
|
||||||
|
card['content'])
|
||||||
|
|
||||||
|
handler.write_json(response.as_dict())
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechType(enum.Enum):
|
||||||
|
""" Alexa speech types. """
|
||||||
|
plaintext = "PlainText"
|
||||||
|
ssml = "SSML"
|
||||||
|
|
||||||
|
|
||||||
|
class CardType(enum.Enum):
|
||||||
|
""" Alexa card types. """
|
||||||
|
simple = "Simple"
|
||||||
|
link_account = "LinkAccount"
|
||||||
|
|
||||||
|
|
||||||
|
class AlexaResponse(object):
|
||||||
|
""" Helps generating the response for Alexa. """
|
||||||
|
|
||||||
|
def __init__(self, hass, intent=None):
|
||||||
|
self.hass = hass
|
||||||
|
self.speech = None
|
||||||
|
self.card = None
|
||||||
|
self.reprompt = None
|
||||||
|
self.session_attributes = {}
|
||||||
|
self.should_end_session = True
|
||||||
|
if intent is not None and 'slots' in intent:
|
||||||
|
self.variables = {key: value['value'] for key, value
|
||||||
|
in intent['slots'].items()}
|
||||||
|
else:
|
||||||
|
self.variables = {}
|
||||||
|
|
||||||
|
def add_card(self, card_type, title, content):
|
||||||
|
""" Add a card to the response. """
|
||||||
|
assert self.card is None
|
||||||
|
|
||||||
|
card = {
|
||||||
|
"type": card_type.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if card_type == CardType.link_account:
|
||||||
|
self.card = card
|
||||||
|
return
|
||||||
|
|
||||||
|
card["title"] = self._render(title),
|
||||||
|
card["content"] = self._render(content)
|
||||||
|
self.card = card
|
||||||
|
|
||||||
|
def add_speech(self, speech_type, text):
|
||||||
|
""" Add speech to the response. """
|
||||||
|
assert self.speech is None
|
||||||
|
|
||||||
|
key = 'ssml' if speech_type == SpeechType.ssml else 'text'
|
||||||
|
|
||||||
|
self.speech = {
|
||||||
|
'type': speech_type.value,
|
||||||
|
key: self._render(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_reprompt(self, speech_type, text):
|
||||||
|
""" Add repromopt if user does not answer. """
|
||||||
|
assert self.reprompt is None
|
||||||
|
|
||||||
|
key = 'ssml' if speech_type == SpeechType.ssml else 'text'
|
||||||
|
|
||||||
|
self.reprompt = {
|
||||||
|
'type': speech_type.value,
|
||||||
|
key: self._render(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
def as_dict(self):
|
||||||
|
""" Returns response in an Alexa valid dict. """
|
||||||
|
response = {
|
||||||
|
'shouldEndSession': self.should_end_session
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.card is not None:
|
||||||
|
response['card'] = self.card
|
||||||
|
|
||||||
|
if self.speech is not None:
|
||||||
|
response['outputSpeech'] = self.speech
|
||||||
|
|
||||||
|
if self.reprompt is not None:
|
||||||
|
response['reprompt'] = {
|
||||||
|
'outputSpeech': self.reprompt
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'version': '1.0',
|
||||||
|
'sessionAttributes': self.session_attributes,
|
||||||
|
'response': response,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _render(self, template_string):
|
||||||
|
""" Render a response, adding data from intent if available. """
|
||||||
|
return template.render(self.hass, template_string, self.variables)
|
|
@ -178,12 +178,8 @@ class RequestHandler(SimpleHTTPRequestHandler):
|
||||||
""" Does some common checks and calls appropriate method. """
|
""" Does some common checks and calls appropriate method. """
|
||||||
url = urlparse(self.path)
|
url = urlparse(self.path)
|
||||||
|
|
||||||
# Read query input
|
# Read query input. parse_qs gives a list for each value, we want last
|
||||||
data = parse_qs(url.query)
|
data = {key: data[-1] for key, data in parse_qs(url.query).items()}
|
||||||
|
|
||||||
# parse_qs gives a list for each value, take the latest element
|
|
||||||
for key in data:
|
|
||||||
data[key] = data[key][-1]
|
|
||||||
|
|
||||||
# Did we get post input ?
|
# Did we get post input ?
|
||||||
content_length = int(self.headers.get(HTTP_HEADER_CONTENT_LENGTH, 0))
|
content_length = int(self.headers.get(HTTP_HEADER_CONTENT_LENGTH, 0))
|
||||||
|
|
|
@ -9,6 +9,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import jinja2
|
import jinja2
|
||||||
from jinja2.sandbox import ImmutableSandboxedEnvironment
|
from jinja2.sandbox import ImmutableSandboxedEnvironment
|
||||||
|
from homeassistant.const import STATE_UNKNOWN
|
||||||
from homeassistant.exceptions import TemplateError
|
from homeassistant.exceptions import TemplateError
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -41,8 +42,9 @@ def render(hass, template, variables=None, **kwargs):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return ENV.from_string(template, {
|
return ENV.from_string(template, {
|
||||||
'states': AllStates(hass)
|
'states': AllStates(hass),
|
||||||
}).render(kwargs)
|
'is_state': hass.states.is_state
|
||||||
|
}).render(kwargs).strip()
|
||||||
except jinja2.TemplateError as err:
|
except jinja2.TemplateError as err:
|
||||||
raise TemplateError(err)
|
raise TemplateError(err)
|
||||||
|
|
||||||
|
@ -59,6 +61,10 @@ class AllStates(object):
|
||||||
return iter(sorted(self._hass.states.all(),
|
return iter(sorted(self._hass.states.all(),
|
||||||
key=lambda state: state.entity_id))
|
key=lambda state: state.entity_id))
|
||||||
|
|
||||||
|
def __call__(self, entity_id):
|
||||||
|
state = self._hass.states.get(entity_id)
|
||||||
|
return STATE_UNKNOWN if state is None else state.state
|
||||||
|
|
||||||
|
|
||||||
class DomainStates(object):
|
class DomainStates(object):
|
||||||
""" Class to expose a specific HA domain as attributes. """
|
""" Class to expose a specific HA domain as attributes. """
|
||||||
|
@ -95,6 +101,13 @@ def multiply(value, amount):
|
||||||
# If value can't be converted to float
|
# If value can't be converted to float
|
||||||
return value
|
return value
|
||||||
|
|
||||||
ENV = ImmutableSandboxedEnvironment()
|
|
||||||
|
class TemplateEnvironment(ImmutableSandboxedEnvironment):
|
||||||
|
""" Home Assistant template environment. """
|
||||||
|
|
||||||
|
def is_safe_callable(self, obj):
|
||||||
|
return isinstance(obj, AllStates) or super().is_safe_callable(obj)
|
||||||
|
|
||||||
|
ENV = TemplateEnvironment()
|
||||||
ENV.filters['round'] = forgiving_round
|
ENV.filters['round'] = forgiving_round
|
||||||
ENV.filters['multiply'] = multiply
|
ENV.filters['multiply'] = multiply
|
||||||
|
|
|
@ -0,0 +1,225 @@
|
||||||
|
"""
|
||||||
|
tests.test_component_alexa
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Tests Home Assistant Alexa component does what it should do.
|
||||||
|
"""
|
||||||
|
# pylint: disable=protected-access,too-many-public-methods
|
||||||
|
import unittest
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from homeassistant import bootstrap, const
|
||||||
|
import homeassistant.core as ha
|
||||||
|
from homeassistant.components import alexa, http
|
||||||
|
|
||||||
|
API_PASSWORD = "test1234"
|
||||||
|
|
||||||
|
# Somehow the socket that holds the default port does not get released
|
||||||
|
# when we close down HA in a different test case. Until I have figured
|
||||||
|
# out what is going on, let's run this test on a different port.
|
||||||
|
SERVER_PORT = 8119
|
||||||
|
|
||||||
|
API_URL = "http://127.0.0.1:{}{}".format(SERVER_PORT, alexa.API_ENDPOINT)
|
||||||
|
|
||||||
|
HA_HEADERS = {const.HTTP_HEADER_HA_AUTH: API_PASSWORD}
|
||||||
|
|
||||||
|
hass = None
|
||||||
|
|
||||||
|
|
||||||
|
@patch('homeassistant.components.http.util.get_local_ip',
|
||||||
|
return_value='127.0.0.1')
|
||||||
|
def setUpModule(mock_get_local_ip): # pylint: disable=invalid-name
|
||||||
|
""" Initalizes a Home Assistant server. """
|
||||||
|
global hass
|
||||||
|
|
||||||
|
hass = ha.HomeAssistant()
|
||||||
|
|
||||||
|
bootstrap.setup_component(
|
||||||
|
hass, http.DOMAIN,
|
||||||
|
{http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD,
|
||||||
|
http.CONF_SERVER_PORT: SERVER_PORT}})
|
||||||
|
|
||||||
|
bootstrap.setup_component(hass, alexa.DOMAIN, {
|
||||||
|
'alexa': {
|
||||||
|
'intents': {
|
||||||
|
'WhereAreWeIntent': {
|
||||||
|
'speech': {
|
||||||
|
'type': 'plaintext',
|
||||||
|
'text':
|
||||||
|
"""
|
||||||
|
{%- if is_state('device_tracker.paulus', 'home') and is_state('device_tracker.anne_therese', 'home') -%}
|
||||||
|
You are both home, you silly
|
||||||
|
{%- else -%}
|
||||||
|
Anne Therese is at {{ states("device_tracker.anne_therese") }} and Paulus is at {{ states("device_tracker.paulus") }}
|
||||||
|
{% endif %}
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'GetZodiacHoroscopeIntent': {
|
||||||
|
'speech': {
|
||||||
|
'type': 'plaintext',
|
||||||
|
'text': 'You told us your sign is {{ ZodiacSign }}.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hass.start()
|
||||||
|
|
||||||
|
|
||||||
|
def tearDownModule(): # pylint: disable=invalid-name
|
||||||
|
""" Stops the Home Assistant server. """
|
||||||
|
hass.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def _req(data={}):
|
||||||
|
return requests.post(API_URL, data=json.dumps(data), timeout=5,
|
||||||
|
headers=HA_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlexa(unittest.TestCase):
|
||||||
|
""" Test Alexa. """
|
||||||
|
|
||||||
|
def test_launch_request(self):
|
||||||
|
data = {
|
||||||
|
'version': '1.0',
|
||||||
|
'session': {
|
||||||
|
'new': True,
|
||||||
|
'sessionId': 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000',
|
||||||
|
'application': {
|
||||||
|
'applicationId': 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe'
|
||||||
|
},
|
||||||
|
'attributes': {},
|
||||||
|
'user': {
|
||||||
|
'userId': 'amzn1.account.AM3B00000000000000000000000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'request': {
|
||||||
|
'type': 'LaunchRequest',
|
||||||
|
'requestId': 'amzn1.echo-api.request.0000000-0000-0000-0000-00000000000',
|
||||||
|
'timestamp': '2015-05-13T12:34:56Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req = _req(data)
|
||||||
|
self.assertEqual(200, req.status_code)
|
||||||
|
resp = req.json()
|
||||||
|
self.assertIn('outputSpeech', resp['response'])
|
||||||
|
|
||||||
|
def test_intent_request_with_slots(self):
|
||||||
|
data = {
|
||||||
|
'version': '1.0',
|
||||||
|
'session': {
|
||||||
|
'new': False,
|
||||||
|
'sessionId': 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000',
|
||||||
|
'application': {
|
||||||
|
'applicationId': 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe'
|
||||||
|
},
|
||||||
|
'attributes': {
|
||||||
|
'supportedHoroscopePeriods': {
|
||||||
|
'daily': True,
|
||||||
|
'weekly': False,
|
||||||
|
'monthly': False
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'user': {
|
||||||
|
'userId': 'amzn1.account.AM3B00000000000000000000000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'request': {
|
||||||
|
'type': 'IntentRequest',
|
||||||
|
'requestId': ' amzn1.echo-api.request.0000000-0000-0000-0000-00000000000',
|
||||||
|
'timestamp': '2015-05-13T12:34:56Z',
|
||||||
|
'intent': {
|
||||||
|
'name': 'GetZodiacHoroscopeIntent',
|
||||||
|
'slots': {
|
||||||
|
'ZodiacSign': {
|
||||||
|
'name': 'ZodiacSign',
|
||||||
|
'value': 'virgo'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req = _req(data)
|
||||||
|
self.assertEqual(200, req.status_code)
|
||||||
|
text = req.json().get('response', {}).get('outputSpeech', {}).get('text')
|
||||||
|
self.assertEqual('You told us your sign is virgo.', text)
|
||||||
|
|
||||||
|
def test_intent_request_without_slots(self):
|
||||||
|
data = {
|
||||||
|
'version': '1.0',
|
||||||
|
'session': {
|
||||||
|
'new': False,
|
||||||
|
'sessionId': 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000',
|
||||||
|
'application': {
|
||||||
|
'applicationId': 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe'
|
||||||
|
},
|
||||||
|
'attributes': {
|
||||||
|
'supportedHoroscopePeriods': {
|
||||||
|
'daily': True,
|
||||||
|
'weekly': False,
|
||||||
|
'monthly': False
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'user': {
|
||||||
|
'userId': 'amzn1.account.AM3B00000000000000000000000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'request': {
|
||||||
|
'type': 'IntentRequest',
|
||||||
|
'requestId': ' amzn1.echo-api.request.0000000-0000-0000-0000-00000000000',
|
||||||
|
'timestamp': '2015-05-13T12:34:56Z',
|
||||||
|
'intent': {
|
||||||
|
'name': 'WhereAreWeIntent',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req = _req(data)
|
||||||
|
self.assertEqual(200, req.status_code)
|
||||||
|
text = req.json().get('response', {}).get('outputSpeech', {}).get('text')
|
||||||
|
|
||||||
|
self.assertEqual('Anne Therese is at unknown and Paulus is at unknown', text)
|
||||||
|
|
||||||
|
hass.states.set('device_tracker.paulus', 'home')
|
||||||
|
hass.states.set('device_tracker.anne_therese', 'home')
|
||||||
|
|
||||||
|
req = _req(data)
|
||||||
|
self.assertEqual(200, req.status_code)
|
||||||
|
text = req.json().get('response', {}).get('outputSpeech', {}).get('text')
|
||||||
|
self.assertEqual('You are both home, you silly', text)
|
||||||
|
|
||||||
|
def test_session_ended_request(self):
|
||||||
|
data = {
|
||||||
|
'version': '1.0',
|
||||||
|
'session': {
|
||||||
|
'new': False,
|
||||||
|
'sessionId': 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000',
|
||||||
|
'application': {
|
||||||
|
'applicationId': 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe'
|
||||||
|
},
|
||||||
|
'attributes': {
|
||||||
|
'supportedHoroscopePeriods': {
|
||||||
|
'daily': True,
|
||||||
|
'weekly': False,
|
||||||
|
'monthly': False
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'user': {
|
||||||
|
'userId': 'amzn1.account.AM3B00000000000000000000000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'request': {
|
||||||
|
'type': 'SessionEndedRequest',
|
||||||
|
'requestId': 'amzn1.echo-api.request.0000000-0000-0000-0000-00000000000',
|
||||||
|
'timestamp': '2015-05-13T12:34:56Z',
|
||||||
|
'reason': 'USER_INITIATED'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req = _req(data)
|
||||||
|
self.assertEqual(200, req.status_code)
|
||||||
|
self.assertEqual('', req.text)
|
|
@ -100,3 +100,28 @@ class TestUtilTemplate(unittest.TestCase):
|
||||||
def test_raise_exception_on_error(self):
|
def test_raise_exception_on_error(self):
|
||||||
with self.assertRaises(TemplateError):
|
with self.assertRaises(TemplateError):
|
||||||
template.render(self.hass, '{{ invalid_syntax')
|
template.render(self.hass, '{{ invalid_syntax')
|
||||||
|
|
||||||
|
def test_if_state_exists(self):
|
||||||
|
self.hass.states.set('test.object', 'available')
|
||||||
|
self.assertEqual(
|
||||||
|
'exists',
|
||||||
|
template.render(
|
||||||
|
self.hass,
|
||||||
|
'{% if states.test.object %}exists{% else %}not exists{% endif %}'))
|
||||||
|
|
||||||
|
def test_is_state(self):
|
||||||
|
self.hass.states.set('test.object', 'available')
|
||||||
|
self.assertEqual(
|
||||||
|
'yes',
|
||||||
|
template.render(
|
||||||
|
self.hass,
|
||||||
|
'{% if is_state("test.object", "available") %}yes{% else %}no{% endif %}'))
|
||||||
|
|
||||||
|
def test_states_function(self):
|
||||||
|
self.hass.states.set('test.object', 'available')
|
||||||
|
self.assertEqual(
|
||||||
|
'available',
|
||||||
|
template.render(self.hass, '{{ states("test.object") }}'))
|
||||||
|
self.assertEqual(
|
||||||
|
'unknown',
|
||||||
|
template.render(self.hass, '{{ states("test.object2") }}'))
|
||||||
|
|
Loading…
Reference in New Issue