diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py index 94d5b24cbf0..64ff50af323 100644 --- a/homeassistant/components/alexa.py +++ b/homeassistant/components/alexa.py @@ -7,16 +7,20 @@ https://home-assistant.io/components/alexa/ import copy import enum import logging +import uuid +from datetime import datetime import voluptuous as vol from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.helpers import template, script, config_validation as cv from homeassistant.components.http import HomeAssistantView +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -API_ENDPOINT = '/api/alexa' +INTENTS_API_ENDPOINT = '/api/alexa' +FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/' CONF_ACTION = 'action' CONF_CARD = 'card' @@ -28,6 +32,23 @@ CONF_TITLE = 'title' CONF_CONTENT = 'content' CONF_TEXT = 'text' +CONF_FLASH_BRIEFINGS = 'flash_briefings' +CONF_UID = 'uid' +CONF_DATE = 'date' +CONF_TITLE = 'title' +CONF_AUDIO = 'audio' +CONF_TEXT = 'text' +CONF_DISPLAY_URL = 'display_url' + +ATTR_UID = 'uid' +ATTR_UPDATE_DATE = 'updateDate' +ATTR_TITLE_TEXT = 'titleText' +ATTR_STREAM_URL = 'streamUrl' +ATTR_MAIN_TEXT = 'mainText' +ATTR_REDIRECTION_URL = 'redirectionURL' + +DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' + DOMAIN = 'alexa' DEPENDENCIES = ['http'] @@ -61,6 +82,16 @@ CONFIG_SCHEMA = vol.Schema({ 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, + vol.Optional(CONF_DATE, default=datetime.utcnow()): cv.string, + vol.Required(CONF_TITLE): cv.template, + vol.Optional(CONF_AUDIO): cv.template, + vol.Required(CONF_TEXT, default=""): cv.template, + vol.Optional(CONF_DISPLAY_URL): cv.template, + }]), } } }, extra=vol.ALLOW_EXTRA) @@ -68,16 +99,19 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Activate Alexa component.""" - hass.wsgi.register_view(AlexaView(hass, - config[DOMAIN].get(CONF_INTENTS, {}))) + intents = config[DOMAIN].get(CONF_INTENTS, {}) + flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {}) + + hass.wsgi.register_view(AlexaIntentsView(hass, intents)) + hass.wsgi.register_view(AlexaFlashBriefingView(hass, flash_briefings)) return True -class AlexaView(HomeAssistantView): +class AlexaIntentsView(HomeAssistantView): """Handle Alexa requests.""" - url = API_ENDPOINT + url = INTENTS_API_ENDPOINT name = 'api:alexa' def __init__(self, hass, intents): @@ -235,3 +269,69 @@ class AlexaResponse(object): 'sessionAttributes': self.session_attributes, 'response': response, } + + +class AlexaFlashBriefingView(HomeAssistantView): + """Handle Alexa Flash Briefing skill requests.""" + + url = FLASH_BRIEFINGS_API_ENDPOINT + name = 'api:alexa:flash_briefings' + + def __init__(self, hass, flash_briefings): + """Initialize Alexa view.""" + super().__init__(hass) + self.flash_briefings = copy.deepcopy(flash_briefings) + template.attach(hass, self.flash_briefings) + + # pylint: disable=too-many-branches + def get(self, request, briefing_id): + """Handle Alexa Flash Briefing request.""" + _LOGGER.debug('Received Alexa flash briefing request for: %s', + briefing_id) + + if self.flash_briefings.get(briefing_id) is None: + err = 'No configured Alexa flash briefing was found for: %s' + _LOGGER.error(err, briefing_id) + return self.Response(status=404) + + briefing = [] + + for item in self.flash_briefings.get(briefing_id, []): + output = {} + if item.get(CONF_TITLE) is not None: + if isinstance(item.get(CONF_TITLE), template.Template): + output[ATTR_TITLE_TEXT] = item[CONF_TITLE].render() + else: + output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) + + if item.get(CONF_TEXT) is not None: + if isinstance(item.get(CONF_TEXT), template.Template): + output[ATTR_MAIN_TEXT] = item[CONF_TEXT].render() + else: + output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) + + if item.get(CONF_UID) is not None: + output[ATTR_UID] = item.get(CONF_UID) + + if item.get(CONF_AUDIO) is not None: + if isinstance(item.get(CONF_AUDIO), template.Template): + output[ATTR_STREAM_URL] = item[CONF_AUDIO].render() + else: + output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) + + if item.get(CONF_DISPLAY_URL) is not None: + if isinstance(item.get(CONF_DISPLAY_URL), + template.Template): + output[ATTR_REDIRECTION_URL] = \ + item[CONF_DISPLAY_URL].render() + else: + output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) + + if isinstance(item[CONF_DATE], str): + item[CONF_DATE] = dt_util.parse_datetime(item[CONF_DATE]) + + output[ATTR_UPDATE_DATE] = item[CONF_DATE].strftime(DATE_FORMAT) + + briefing.append(output) + + return self.json(briefing) diff --git a/tests/components/test_alexa.py b/tests/components/test_alexa.py index a40b401c777..31d5d6eec5c 100644 --- a/tests/components/test_alexa.py +++ b/tests/components/test_alexa.py @@ -2,6 +2,7 @@ # pylint: disable=protected-access,too-many-public-methods import json import time +import datetime import unittest import requests @@ -13,19 +14,27 @@ from tests.common import get_test_instance_port, get_test_home_assistant API_PASSWORD = "test1234" SERVER_PORT = get_test_instance_port() -API_URL = "http://127.0.0.1:{}{}".format(SERVER_PORT, alexa.API_ENDPOINT) +BASE_API_URL = "http://127.0.0.1:{}".format(SERVER_PORT) +INTENTS_API_URL = "{}{}".format(BASE_API_URL, alexa.INTENTS_API_ENDPOINT) + HA_HEADERS = { const.HTTP_HEADER_HA_AUTH: API_PASSWORD, const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, } -SESSION_ID = 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000' -APPLICATION_ID = 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe' -REQUEST_ID = 'amzn1.echo-api.request.0000000-0000-0000-0000-00000000000' +SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000" +APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" +REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000" +# pylint: disable=invalid-name hass = None calls = [] +NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" + +# 2016-10-10T19:51:42+00:00 +STATIC_TIME = datetime.datetime.utcfromtimestamp(1476129102) + def setUpModule(): # pylint: disable=invalid-name """Initialize a Home Assistant server for testing this module.""" @@ -36,23 +45,40 @@ def setUpModule(): # pylint: disable=invalid-name bootstrap.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, - http.CONF_SERVER_PORT: SERVER_PORT}}) + http.CONF_SERVER_PORT: SERVER_PORT}}) - hass.services.register('test', 'alexa', lambda call: calls.append(call)) + hass.services.register("test", "alexa", lambda call: calls.append(call)) bootstrap.setup_component(hass, alexa.DOMAIN, { # Key is here to verify we allow other keys in config too - 'homeassistant': {}, - 'alexa': { - 'intents': { - 'WhereAreWeIntent': { - 'speech': { - 'type': 'plaintext', - 'text': + "homeassistant": {}, + "alexa": { + "flash_briefings": { + "weather": [ + {"title": "Weekly forecast", + "text": "This week it will be sunny.", + "date": "2016-10-09T19:51:42.0Z"}, + {"title": "Current conditions", + "text": "Currently it is 80 degrees fahrenheit.", + "date": STATIC_TIME} + ], + "news_audio": { + "title": "NPR", + "audio": NPR_NEWS_MP3_URL, + "display_url": "https://npr.org", + "date": STATIC_TIME, + "uid": "uuid" + } + }, + "intents": { + "WhereAreWeIntent": { + "speech": { + "type": "plaintext", + "text": """ - {%- if is_state('device_tracker.paulus', 'home') - and is_state('device_tracker.anne_therese', - 'home') -%} + {%- 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 {{ @@ -64,23 +90,23 @@ def setUpModule(): # pylint: disable=invalid-name """, } }, - 'GetZodiacHoroscopeIntent': { - 'speech': { - 'type': 'plaintext', - 'text': 'You told us your sign is {{ ZodiacSign }}.', + "GetZodiacHoroscopeIntent": { + "speech": { + "type": "plaintext", + "text": "You told us your sign is {{ ZodiacSign }}.", } }, - 'CallServiceIntent': { - 'speech': { - 'type': 'plaintext', - 'text': 'Service called', + "CallServiceIntent": { + "speech": { + "type": "plaintext", + "text": "Service called", }, - 'action': { - 'service': 'test.alexa', - 'data_template': { - 'hello': '{{ ZodiacSign }}' + "action": { + "service": "test.alexa", + "data_template": { + "hello": "{{ ZodiacSign }}" }, - 'entity_id': 'switch.test', + "entity_id": "switch.test", } } } @@ -96,11 +122,19 @@ def tearDownModule(): # pylint: disable=invalid-name hass.stop() -def _req(data={}): - return requests.post(API_URL, data=json.dumps(data), timeout=5, +def _intent_req(data={}): + return requests.post(INTENTS_API_URL, data=json.dumps(data), timeout=5, headers=HA_HEADERS) +def _flash_briefing_req(briefing_id=None): + url_format = "{}/api/alexa/flash_briefings/{}" + FLASH_BRIEFING_API_URL = url_format.format(BASE_API_URL, + briefing_id) + return requests.get(FLASH_BRIEFING_API_URL, timeout=5, + headers=HA_HEADERS) + + class TestAlexa(unittest.TestCase): """Test Alexa.""" @@ -108,231 +142,267 @@ class TestAlexa(unittest.TestCase): """Stop everything that was started.""" hass.block_till_done() - def test_launch_request(self): + def test_intent_launch_request(self): """Test the launch of a request.""" data = { - 'version': '1.0', - 'session': { - 'new': True, - 'sessionId': SESSION_ID, - 'application': { - 'applicationId': APPLICATION_ID + "version": "1.0", + "session": { + "new": True, + "sessionId": SESSION_ID, + "application": { + "applicationId": APPLICATION_ID }, - 'attributes': {}, - 'user': { - 'userId': 'amzn1.account.AM3B00000000000000000000000' + "attributes": {}, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" } }, - 'request': { - 'type': 'LaunchRequest', - 'requestId': REQUEST_ID, - 'timestamp': '2015-05-13T12:34:56Z' + "request": { + "type": "LaunchRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z" } } - req = _req(data) + req = _intent_req(data) self.assertEqual(200, req.status_code) resp = req.json() - self.assertIn('outputSpeech', resp['response']) + self.assertIn("outputSpeech", resp["response"]) def test_intent_request_with_slots(self): """Test a request with slots.""" data = { - 'version': '1.0', - 'session': { - 'new': False, - 'sessionId': SESSION_ID, - 'application': { - 'applicationId': APPLICATION_ID + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": { + "applicationId": APPLICATION_ID }, - 'attributes': { - 'supportedHoroscopePeriods': { - 'daily': True, - 'weekly': False, - 'monthly': False + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False } }, - 'user': { - 'userId': 'amzn1.account.AM3B00000000000000000000000' + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" } }, - 'request': { - 'type': 'IntentRequest', - 'requestId': REQUEST_ID, - 'timestamp': '2015-05-13T12:34:56Z', - 'intent': { - 'name': 'GetZodiacHoroscopeIntent', - 'slots': { - 'ZodiacSign': { - 'name': 'ZodiacSign', - 'value': 'virgo' + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "GetZodiacHoroscopeIntent", + "slots": { + "ZodiacSign": { + "name": "ZodiacSign", + "value": "virgo" } } } } } - req = _req(data) + req = _intent_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) + text = req.json().get("response", {}).get("outputSpeech", + {}).get("text") + self.assertEqual("You told us your sign is virgo.", text) def test_intent_request_with_slots_but_no_value(self): """Test a request with slots but no value.""" data = { - 'version': '1.0', - 'session': { - 'new': False, - 'sessionId': SESSION_ID, - 'application': { - 'applicationId': APPLICATION_ID + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": { + "applicationId": APPLICATION_ID }, - 'attributes': { - 'supportedHoroscopePeriods': { - 'daily': True, - 'weekly': False, - 'monthly': False + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False } }, - 'user': { - 'userId': 'amzn1.account.AM3B00000000000000000000000' + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" } }, - 'request': { - 'type': 'IntentRequest', - 'requestId': REQUEST_ID, - 'timestamp': '2015-05-13T12:34:56Z', - 'intent': { - 'name': 'GetZodiacHoroscopeIntent', - 'slots': { - 'ZodiacSign': { - 'name': 'ZodiacSign', + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "GetZodiacHoroscopeIntent", + "slots": { + "ZodiacSign": { + "name": "ZodiacSign", } } } } } - req = _req(data) + req = _intent_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 .', text) + text = req.json().get("response", {}).get("outputSpeech", + {}).get("text") + self.assertEqual("You told us your sign is .", text) def test_intent_request_without_slots(self): """Test a request without slots.""" data = { - 'version': '1.0', - 'session': { - 'new': False, - 'sessionId': SESSION_ID, - 'application': { - 'applicationId': APPLICATION_ID + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": { + "applicationId": APPLICATION_ID }, - 'attributes': { - 'supportedHoroscopePeriods': { - 'daily': True, - 'weekly': False, - 'monthly': False + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False } }, - 'user': { - 'userId': 'amzn1.account.AM3B00000000000000000000000' + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" } }, - 'request': { - 'type': 'IntentRequest', - 'requestId': REQUEST_ID, - 'timestamp': '2015-05-13T12:34:56Z', - 'intent': { - 'name': 'WhereAreWeIntent', + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "WhereAreWeIntent", } } } - req = _req(data) + req = _intent_req(data) self.assertEqual(200, req.status_code) - text = req.json().get('response', {}).get('outputSpeech', - {}).get('text') + text = req.json().get("response", {}).get("outputSpeech", + {}).get("text") - self.assertEqual('Anne Therese is at unknown and Paulus is at unknown', + 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') + hass.states.set("device_tracker.paulus", "home") + hass.states.set("device_tracker.anne_therese", "home") - req = _req(data) + req = _intent_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) + text = req.json().get("response", {}).get("outputSpeech", + {}).get("text") + self.assertEqual("You are both home, you silly", text) def test_intent_request_calling_service(self): """Test a request for calling a service.""" data = { - 'version': '1.0', - 'session': { - 'new': False, - 'sessionId': SESSION_ID, - 'application': { - 'applicationId': APPLICATION_ID + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": { + "applicationId": APPLICATION_ID }, - 'attributes': {}, - 'user': { - 'userId': 'amzn1.account.AM3B00000000000000000000000' + "attributes": {}, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" } }, - 'request': { - 'type': 'IntentRequest', - 'requestId': REQUEST_ID, - 'timestamp': '2015-05-13T12:34:56Z', - 'intent': { - 'name': 'CallServiceIntent', - 'slots': { - 'ZodiacSign': { - 'name': 'ZodiacSign', - 'value': 'virgo', + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "CallServiceIntent", + "slots": { + "ZodiacSign": { + "name": "ZodiacSign", + "value": "virgo", } } } } } call_count = len(calls) - req = _req(data) + req = _intent_req(data) self.assertEqual(200, req.status_code) self.assertEqual(call_count + 1, len(calls)) call = calls[-1] - self.assertEqual('test', call.domain) - self.assertEqual('alexa', call.service) - self.assertEqual(['switch.test'], call.data.get('entity_id')) - self.assertEqual('virgo', call.data.get('hello')) + self.assertEqual("test", call.domain) + self.assertEqual("alexa", call.service) + self.assertEqual(["switch.test"], call.data.get("entity_id")) + self.assertEqual("virgo", call.data.get("hello")) - def test_session_ended_request(self): + def test_intent_session_ended_request(self): """Test the request for ending the session.""" data = { - 'version': '1.0', - 'session': { - 'new': False, - 'sessionId': SESSION_ID, - 'application': { - 'applicationId': APPLICATION_ID + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": { + "applicationId": APPLICATION_ID }, - 'attributes': { - 'supportedHoroscopePeriods': { - 'daily': True, - 'weekly': False, - 'monthly': False + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False } }, - 'user': { - 'userId': 'amzn1.account.AM3B00000000000000000000000' + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" } }, - 'request': { - 'type': 'SessionEndedRequest', - 'requestId': REQUEST_ID, - 'timestamp': '2015-05-13T12:34:56Z', - 'reason': 'USER_INITIATED' + "request": { + "type": "SessionEndedRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "reason": "USER_INITIATED" } } - req = _req(data) + req = _intent_req(data) self.assertEqual(200, req.status_code) - self.assertEqual('', req.text) + self.assertEqual("", req.text) + + def test_flash_briefing_invalid_id(self): + """Test an invalid Flash Briefing ID.""" + req = _flash_briefing_req() + self.assertEqual(404, req.status_code) + self.assertEqual("", req.text) + + def test_flash_briefing_date_from_str(self): + """Test the response has a valid date parsed from string.""" + req = _flash_briefing_req("weather") + self.assertEqual(200, req.status_code) + self.assertEqual(req.json()[0].get(alexa.ATTR_UPDATE_DATE), + "2016-10-09T19:51:42.0Z") + + def test_flash_briefing_date_from_datetime(self): + """Test the response has a valid date from a datetime object.""" + req = _flash_briefing_req("weather") + self.assertEqual(200, req.status_code) + self.assertEqual(req.json()[1].get(alexa.ATTR_UPDATE_DATE), + '2016-10-10T19:51:42.0Z') + + def test_flash_briefing_valid(self): + """Test the response is valid.""" + data = [{ + "titleText": "NPR", + "redirectionURL": "https://npr.org", + "streamUrl": NPR_NEWS_MP3_URL, + "mainText": "", + "uid": "uuid", + "updateDate": '2016-10-10T19:51:42.0Z' + }] + + req = _flash_briefing_req("news_audio") + self.assertEqual(200, req.status_code) + response = req.json() + self.assertEqual(response, data)