Expose Alexa Smart Home via HTTP POST (#11859)

* Expose Alexa Smart Home via HTTP POST

Haaska uses the deprecated v2 Alexa Smart Home payload. Exposing the v3
implementation this way allows an easy path to upgrading Haaska and
reducing code duplication with Home Assistant Cloud.

* Expose Alexa Smart Home via HTTP POST

Haaska uses the deprecated v2 Alexa Smart Home payload. Exposing the v3
implementation this way allows an easy path to upgrading Haaska and
reducing code duplication with Home Assistant Cloud.
This commit is contained in:
Phil Frost 2018-01-23 18:45:28 +00:00 committed by Paulus Schoutsen
parent 73fa76d792
commit 990e076c2c
4 changed files with 131 additions and 7 deletions

View file

@ -10,17 +10,34 @@ import logging
import voluptuous as vol
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import entityfilter
from . import flash_briefings, intent
from . import flash_briefings, intent, smart_home
from .const import (
CONF_AUDIO, CONF_DISPLAY_URL, CONF_TEXT, CONF_TITLE, CONF_UID, DOMAIN)
CONF_AUDIO, CONF_DISPLAY_URL, CONF_TEXT, CONF_TITLE, CONF_UID, DOMAIN,
CONF_FILTER, CONF_ENTITY_CONFIG)
_LOGGER = logging.getLogger(__name__)
CONF_FLASH_BRIEFINGS = 'flash_briefings'
CONF_SMART_HOME = 'smart_home'
DEPENDENCIES = ['http']
ALEXA_ENTITY_SCHEMA = vol.Schema({
vol.Optional(smart_home.CONF_DESCRIPTION): cv.string,
vol.Optional(smart_home.CONF_DISPLAY_CATEGORIES): cv.string,
vol.Optional(smart_home.CONF_NAME): cv.string,
})
SMART_HOME_SCHEMA = vol.Schema({
vol.Optional(
CONF_FILTER,
default=lambda: entityfilter.generate_filter([], [], [], [])
): entityfilter.FILTER_SCHEMA,
vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA}
})
CONFIG_SCHEMA = vol.Schema({
DOMAIN: {
CONF_FLASH_BRIEFINGS: {
@ -31,7 +48,10 @@ CONFIG_SCHEMA = vol.Schema({
vol.Required(CONF_TEXT, default=""): cv.template,
vol.Optional(CONF_DISPLAY_URL): cv.template,
}]),
}
},
# vol.Optional here would mean we couldn't distinguish between an empty
# smart_home: and none at all.
CONF_SMART_HOME: vol.Any(SMART_HOME_SCHEMA, None),
}
}, extra=vol.ALLOW_EXTRA)
@ -47,4 +67,12 @@ def async_setup(hass, config):
if flash_briefings_config:
flash_briefings.async_setup(hass, flash_briefings_config)
try:
smart_home_config = config[CONF_SMART_HOME]
except KeyError:
pass
else:
smart_home_config = smart_home_config or SMART_HOME_SCHEMA({})
smart_home.async_setup(hass, smart_home_config)
return True

View file

@ -8,6 +8,9 @@ CONF_AUDIO = 'audio'
CONF_TEXT = 'text'
CONF_DISPLAY_URL = 'display_url'
CONF_FILTER = 'filter'
CONF_ENTITY_CONFIG = 'entity_config'
ATTR_UID = 'uid'
ATTR_UPDATE_DATE = 'updateDate'
ATTR_TITLE_TEXT = 'titleText'

View file

@ -7,16 +7,17 @@ from uuid import uuid4
from homeassistant.components import (
alert, automation, cover, fan, group, input_boolean, light, lock,
media_player, scene, script, switch)
media_player, scene, script, switch, http)
import homeassistant.core as ha
import homeassistant.util.color as color_util
from homeassistant.util.decorator import Registry
from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_NAME, SERVICE_LOCK,
SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP,
SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON,
SERVICE_UNLOCK, SERVICE_VOLUME_SET)
import homeassistant.core as ha
import homeassistant.util.color as color_util
from homeassistant.util.decorator import Registry
from .const import CONF_FILTER, CONF_ENTITY_CONFIG
_LOGGER = logging.getLogger(__name__)
@ -26,6 +27,8 @@ API_EVENT = 'event'
API_HEADER = 'header'
API_PAYLOAD = 'payload'
SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home'
CONF_DESCRIPTION = 'description'
CONF_DISPLAY_CATEGORIES = 'display_categories'
@ -113,6 +116,51 @@ class Config:
self.entity_config = entity_config or {}
@ha.callback
def async_setup(hass, config):
"""Activate Smart Home functionality of Alexa component.
This is optional, triggered by having a `smart_home:` sub-section in the
alexa configuration.
Even if that's disabled, the functionality in this module may still be used
by the cloud component which will call async_handle_message directly.
"""
smart_home_config = Config(
should_expose=config[CONF_FILTER],
entity_config=config.get(CONF_ENTITY_CONFIG),
)
hass.http.register_view(SmartHomeView(smart_home_config))
class SmartHomeView(http.HomeAssistantView):
"""Expose Smart Home v3 payload interface via HTTP POST."""
url = SMART_HOME_HTTP_ENDPOINT
name = 'api:alexa:smart_home'
def __init__(self, smart_home_config):
"""Initialize."""
self.smart_home_config = smart_home_config
@asyncio.coroutine
def post(self, request):
"""Handle Alexa Smart Home requests.
The Smart Home API requires the endpoint to be implemented in AWS
Lambda, which will need to forward the requests to here and pass back
the response.
"""
hass = request.app['hass']
message = yield from request.json()
_LOGGER.debug("Received Alexa Smart Home request: %s", message)
response = yield from async_handle_message(
hass, self.smart_home_config, message)
return b'' if response is None else self.json(response)
@asyncio.coroutine
def async_handle_message(hass, config, message):
"""Handle incoming API messages."""

View file

@ -1,9 +1,12 @@
"""Test for smart home alexa support."""
import asyncio
import json
from uuid import uuid4
import pytest
from homeassistant.setup import async_setup_component
from homeassistant.components import alexa
from homeassistant.components.alexa import smart_home
from homeassistant.helpers import entityfilter
@ -1155,3 +1158,45 @@ def test_entity_config(hass):
assert len(appliance['capabilities']) == 1
assert appliance['capabilities'][-1]['interface'] == \
'Alexa.PowerController'
@asyncio.coroutine
def do_http_discovery(config, hass, test_client):
"""Submit a request to the Smart Home HTTP API."""
yield from async_setup_component(hass, alexa.DOMAIN, config)
http_client = yield from test_client(hass.http.app)
request = get_new_request('Alexa.Discovery', 'Discover')
response = yield from http_client.post(
smart_home.SMART_HOME_HTTP_ENDPOINT,
data=json.dumps(request),
headers={'content-type': 'application/json'})
return response
@asyncio.coroutine
def test_http_api(hass, test_client):
"""With `smart_home:` HTTP API is exposed."""
config = {
'alexa': {
'smart_home': None
}
}
response = yield from do_http_discovery(config, hass, test_client)
response_data = yield from response.json()
# Here we're testing just the HTTP view glue -- details of discovery are
# covered in other tests.
assert response_data['event']['header']['name'] == 'Discover.Response'
@asyncio.coroutine
def test_http_api_disabled(hass, test_client):
"""Without `smart_home:`, the HTTP API is disabled."""
config = {
'alexa': {}
}
response = yield from do_http_discovery(config, hass, test_client)
assert response.status == 404