From 931f7e86152aef707fbd57ffdefe57ee39293151 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 12 Dec 2015 22:18:38 -0800 Subject: [PATCH 1/4] Simplify http component --- homeassistant/components/http.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index 2b260b0e841..81e26aeae5a 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -178,12 +178,8 @@ class RequestHandler(SimpleHTTPRequestHandler): """ Does some common checks and calls appropriate method. """ url = urlparse(self.path) - # Read query input - data = parse_qs(url.query) - - # parse_qs gives a list for each value, take the latest element - for key in data: - data[key] = data[key][-1] + # Read query input. parse_qs gives a list for each value, we want last + data = {key: data[-1] for key, data in parse_qs(url.query).items()} # Did we get post input ? content_length = int(self.headers.get(HTTP_HEADER_CONTENT_LENGTH, 0)) From 360b99be59068a04504b8417d6877a782537cd6e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 12 Dec 2015 22:19:12 -0800 Subject: [PATCH 2/4] Add template is_state method --- homeassistant/util/template.py | 5 +++-- tests/util/test_template.py | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/util/template.py b/homeassistant/util/template.py index ad0fabdab53..f8fa2c70f8b 100644 --- a/homeassistant/util/template.py +++ b/homeassistant/util/template.py @@ -41,8 +41,9 @@ def render(hass, template, variables=None, **kwargs): try: return ENV.from_string(template, { - 'states': AllStates(hass) - }).render(kwargs) + 'states': AllStates(hass), + 'is_state': hass.states.is_state + }).render(kwargs).strip() except jinja2.TemplateError as err: raise TemplateError(err) diff --git a/tests/util/test_template.py b/tests/util/test_template.py index ba354f3e7be..1e34d999fa7 100644 --- a/tests/util/test_template.py +++ b/tests/util/test_template.py @@ -100,3 +100,11 @@ class TestUtilTemplate(unittest.TestCase): def test_raise_exception_on_error(self): with self.assertRaises(TemplateError): template.render(self.hass, '{{ invalid_syntax') + + 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 %}')) From f73f824e0e5df55bb9b86c6771b26c0d262e1d95 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 12 Dec 2015 22:19:37 -0800 Subject: [PATCH 3/4] Make template states var callable --- homeassistant/util/template.py | 14 +++++++++++++- tests/util/test_template.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/template.py b/homeassistant/util/template.py index f8fa2c70f8b..d0a07507bdf 100644 --- a/homeassistant/util/template.py +++ b/homeassistant/util/template.py @@ -9,6 +9,7 @@ import json import logging import jinja2 from jinja2.sandbox import ImmutableSandboxedEnvironment +from homeassistant.const import STATE_UNKNOWN from homeassistant.exceptions import TemplateError _LOGGER = logging.getLogger(__name__) @@ -60,6 +61,10 @@ class AllStates(object): return iter(sorted(self._hass.states.all(), 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 to expose a specific HA domain as attributes. """ @@ -96,6 +101,13 @@ def multiply(value, amount): # If value can't be converted to float 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['multiply'] = multiply diff --git a/tests/util/test_template.py b/tests/util/test_template.py index 1e34d999fa7..1ecd7d5b894 100644 --- a/tests/util/test_template.py +++ b/tests/util/test_template.py @@ -101,6 +101,14 @@ class TestUtilTemplate(unittest.TestCase): with self.assertRaises(TemplateError): 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( @@ -108,3 +116,12 @@ class TestUtilTemplate(unittest.TestCase): 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") }}')) From 729c24d59bada00f0f35a3ae7273a8044a7bb796 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 12 Dec 2015 22:29:02 -0800 Subject: [PATCH 4/4] Add Alexa component --- homeassistant/components/alexa.py | 186 ++++++++++++++++++++++++ tests/components/test_alexa.py | 225 ++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 homeassistant/components/alexa.py create mode 100644 tests/components/test_alexa.py diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py new file mode 100644 index 00000000000..c261cfd3f6a --- /dev/null +++ b/homeassistant/components/alexa.py @@ -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) diff --git a/tests/components/test_alexa.py b/tests/components/test_alexa.py new file mode 100644 index 00000000000..75aec2b087c --- /dev/null +++ b/tests/components/test_alexa.py @@ -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)