Notify Alexa when exposed entities change (#24609)

pull/24620/head
Paulus Schoutsen 2019-06-19 01:06:29 -07:00 committed by Pascal Vizeli
parent a89c8eeabe
commit 6d9f1b3fd3
12 changed files with 436 additions and 68 deletions

View File

@ -168,6 +168,20 @@ class AlexaEntity:
for prop in interface.serialize_properties():
yield prop
def serialize_discovery(self):
"""Serialize the entity for discovery."""
return {
'displayCategories': self.display_categories(),
'cookie': {},
'endpointId': self.alexa_id(),
'friendlyName': self.friendly_name(),
'description': self.description(),
'manufacturerName': 'Home Assistant',
'capabilities': [
i.serialize_discovery() for i in self.interfaces()
]
}
@callback
def async_get_entities(hass, config) -> List[AlexaEntity]:

View File

@ -12,8 +12,12 @@ class UnsupportedProperty(HomeAssistantError):
"""This entity does not support the requested Smart Home API property."""
class NoTokenAvailable(HomeAssistantError):
"""There is no access token available."""
class AlexaError(Exception):
"""Base class for errors that can be serialized by the Alexa API.
"""Base class for errors that can be serialized for the Alexa API.
A handler can raise subclasses of this to return an error to the request.
"""

View File

@ -54,17 +54,7 @@ async def async_api_discovery(hass, config, directive, context):
Async friendly.
"""
discovery_endpoints = [
{
'displayCategories': alexa_entity.display_categories(),
'cookie': {},
'endpointId': alexa_entity.alexa_id(),
'friendlyName': alexa_entity.friendly_name(),
'description': alexa_entity.description(),
'manufacturerName': 'Home Assistant',
'capabilities': [
i.serialize_discovery() for i in alexa_entity.interfaces()
]
}
alexa_entity.serialize_discovery()
for alexa_entity in async_get_entities(hass, config)
if config.should_expose(alexa_entity.entity_id)
]

View File

@ -48,7 +48,7 @@ class AlexaDirective:
self.entity_id = _endpoint_id.replace('#', '.')
self.entity = hass.states.get(self.entity_id)
if not self.entity:
if not self.entity or not config.should_expose(self.entity_id):
raise AlexaInvalidEndpointError(_endpoint_id)
self.endpoint = ENTITY_ADAPTERS[self.entity.domain](

View File

@ -16,41 +16,6 @@ _LOGGER = logging.getLogger(__name__)
EVENT_ALEXA_SMART_HOME = 'alexa_smart_home'
# def _capability(interface,
# version=3,
# supports_deactivation=None,
# retrievable=None,
# properties_supported=None,
# cap_type='AlexaInterface'):
# """Return a Smart Home API capability object.
# https://developer.amazon.com/docs/device-apis/alexa-discovery.html#capability-object
# There are some additional fields allowed but not implemented here since
# we've no use case for them yet:
# - proactively_reported
# `supports_deactivation` applies only to scenes.
# """
# result = {
# 'type': cap_type,
# 'interface': interface,
# 'version': version,
# }
# if supports_deactivation is not None:
# result['supportsDeactivation'] = supports_deactivation
# if retrievable is not None:
# result['retrievable'] = retrievable
# if properties_supported is not None:
# result['properties'] = {'supported': properties_supported}
# return result
async def async_handle_message(
hass,
config,

View File

@ -21,10 +21,6 @@ async def async_enable_proactive_mode(hass, smart_home_config):
Proactive mode makes this component report state changes to Alexa.
"""
if await smart_home_config.async_get_access_token() is None:
# not ready yet
return
async def async_entity_state_listener(changed_entity, old_state,
new_state):
if not new_state:
@ -54,11 +50,11 @@ async def async_enable_proactive_mode(hass, smart_home_config):
async def async_send_changereport_message(hass, config, alexa_entity):
"""Send a ChangeReport message for an Alexa entity."""
"""Send a ChangeReport message for an Alexa entity.
https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-with-changereport-events
"""
token = await config.async_get_access_token()
if not token:
_LOGGER.error("Invalid access token.")
return
headers = {
"Authorization": "Bearer {}".format(token)
@ -83,9 +79,9 @@ async def async_send_changereport_message(hass, config, alexa_entity):
message.set_endpoint_full(token, endpoint)
message_serialized = message.serialize()
session = hass.helpers.aiohttp_client.async_get_clientsession()
try:
session = hass.helpers.aiohttp_client.async_get_clientsession()
with async_timeout.timeout(DEFAULT_TIMEOUT):
response = await session.post(config.endpoint,
headers=headers,
@ -106,3 +102,81 @@ async def async_send_changereport_message(hass, config, alexa_entity):
_LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s",
response_json["payload"]["code"],
response_json["payload"]["description"])
async def async_send_add_or_update_message(hass, config, entity_ids):
"""Send an AddOrUpdateReport message for entities.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report
"""
token = await config.async_get_access_token()
headers = {
"Authorization": "Bearer {}".format(token)
}
endpoints = []
for entity_id in entity_ids:
domain = entity_id.split('.', 1)[0]
alexa_entity = ENTITY_ADAPTERS[domain](
hass, config, hass.states.get(entity_id)
)
endpoints.append(alexa_entity.serialize_discovery())
payload = {
'endpoints': endpoints,
'scope': {
'type': 'BearerToken',
'token': token,
}
}
message = AlexaResponse(
name='AddOrUpdateReport', namespace='Alexa.Discovery', payload=payload)
message_serialized = message.serialize()
session = hass.helpers.aiohttp_client.async_get_clientsession()
return await session.post(config.endpoint, headers=headers,
json=message_serialized, allow_redirects=True)
async def async_send_delete_message(hass, config, entity_ids):
"""Send an DeleteReport message for entities.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event
"""
token = await config.async_get_access_token()
headers = {
"Authorization": "Bearer {}".format(token)
}
endpoints = []
for entity_id in entity_ids:
domain = entity_id.split('.', 1)[0]
alexa_entity = ENTITY_ADAPTERS[domain](
hass, config, hass.states.get(entity_id)
)
endpoints.append({
'endpointId': alexa_entity.alexa_id()
})
payload = {
'endpoints': endpoints,
'scope': {
'type': 'BearerToken',
'token': token,
}
}
message = AlexaResponse(name='DeleteReport', namespace='Alexa.Discovery',
payload=payload)
message_serialized = message.serialize()
session = hass.helpers.aiohttp_client.async_get_clientsession()
return await session.post(config.endpoint, headers=headers,
json=message_serialized, allow_redirects=True)

View File

@ -21,7 +21,8 @@ from .const import (
CONF_CLOUDHOOK_CREATE_URL, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG,
CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL,
CONF_RELAYER, CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_URL,
CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD)
CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD, CONF_ALEXA_ACCESS_TOKEN_URL
)
from .prefs import CloudPreferences
_LOGGER = logging.getLogger(__name__)
@ -72,6 +73,7 @@ CONFIG_SCHEMA = vol.Schema({
vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(),
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
vol.Optional(CONF_ALEXA_ACCESS_TOKEN_URL): str,
}),
}, extra=vol.ALLOW_EXTRA)

View File

@ -6,19 +6,25 @@ from datetime import timedelta
import logging
import aiohttp
import async_timeout
from hass_nabucasa import cloud_api
from hass_nabucasa.client import CloudClient as Interface
from homeassistant.core import callback
from homeassistant.components.alexa import (
config as alexa_config,
errors as alexa_errors,
smart_home as alexa_sh,
entities as alexa_entities,
state_report as alexa_state_report,
)
from homeassistant.components.google_assistant import (
helpers as ga_h, smart_home as ga)
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers import entity_registry
from homeassistant.util.aiohttp import MockRequest
from homeassistant.util.dt import utcnow
@ -31,6 +37,9 @@ from .prefs import CloudPreferences
_LOGGER = logging.getLogger(__name__)
# Time to wait when entity preferences have changed before syncing it to
# the cloud.
SYNC_DELAY = 1
class AlexaConfig(alexa_config.AbstractConfig):
@ -44,7 +53,20 @@ class AlexaConfig(alexa_config.AbstractConfig):
self._cloud = cloud
self._token = None
self._token_valid = None
prefs.async_listen_updates(self.async_prefs_updated)
self._cur_entity_prefs = prefs.alexa_entity_configs
self._alexa_sync_unsub = None
self._endpoint = None
prefs.async_listen_updates(self._async_prefs_updated)
hass.bus.async_listen(
entity_registry.EVENT_ENTITY_REGISTRY_UPDATED,
self._handle_entity_registry_updated
)
@property
def enabled(self):
"""Return if Alexa is enabled."""
return self._prefs.alexa_enabled
@property
def supports_auth(self):
@ -59,7 +81,10 @@ class AlexaConfig(alexa_config.AbstractConfig):
@property
def endpoint(self):
"""Endpoint for report state."""
return None
if self._endpoint is None:
raise ValueError("No endpoint available. Fetch access token first")
return self._endpoint
@property
def entity_config(self):
@ -91,21 +116,143 @@ class AlexaConfig(alexa_config.AbstractConfig):
if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'):
raise RequireRelink
return None
return alexa_errors.NoTokenAvailable
self._token = body['access_token']
self._endpoint = body['event_endpoint']
self._token_valid = utcnow() + timedelta(seconds=body['expires_in'])
return self._token
async def async_prefs_updated(self, prefs):
async def _async_prefs_updated(self, prefs):
"""Handle updated preferences."""
if self.should_report_state == self.is_reporting_states:
if self.should_report_state != self.is_reporting_states:
if self.should_report_state:
await self.async_enable_proactive_mode()
else:
await self.async_disable_proactive_mode()
# If entity prefs are the same or we have filter in config.yaml,
# don't sync.
if (self._cur_entity_prefs is prefs.alexa_entity_configs or
not self._config[CONF_FILTER].empty_filter):
return
if self.should_report_state:
await self.async_enable_proactive_mode()
else:
await self.async_disable_proactive_mode()
if self._alexa_sync_unsub:
self._alexa_sync_unsub()
self._alexa_sync_unsub = async_call_later(
self.hass, SYNC_DELAY, self._sync_prefs)
async def _sync_prefs(self, _now):
"""Sync the updated preferences to Alexa."""
self._alexa_sync_unsub = None
old_prefs = self._cur_entity_prefs
new_prefs = self._prefs.alexa_entity_configs
seen = set()
to_update = []
to_remove = []
for entity_id, info in old_prefs.items():
seen.add(entity_id)
old_expose = info.get(PREF_SHOULD_EXPOSE)
if entity_id in new_prefs:
new_expose = new_prefs[entity_id].get(PREF_SHOULD_EXPOSE)
else:
new_expose = None
if old_expose == new_expose:
continue
if new_expose:
to_update.append(entity_id)
else:
to_remove.append(entity_id)
# Now all the ones that are in new prefs but never were in old prefs
for entity_id, info in new_prefs.items():
if entity_id in seen:
continue
new_expose = info.get(PREF_SHOULD_EXPOSE)
if new_expose is None:
continue
# Only test if we should expose. It can never be a remove action,
# as it didn't exist in old prefs object.
if new_expose:
to_update.append(entity_id)
# We only set the prefs when update is successful, that way we will
# retry when next change comes in.
if await self._sync_helper(to_update, to_remove):
self._cur_entity_prefs = new_prefs
async def async_sync_entities(self):
"""Sync all entities to Alexa."""
to_update = []
to_remove = []
for entity in alexa_entities.async_get_entities(self.hass, self):
if self.should_expose(entity.entity_id):
to_update.append(entity.entity_id)
else:
to_remove.append(entity.entity_id)
return await self._sync_helper(to_update, to_remove)
async def _sync_helper(self, to_update, to_remove) -> bool:
"""Sync entities to Alexa.
Return boolean if it was successful.
"""
if not to_update and not to_remove:
return True
tasks = []
if to_update:
tasks.append(alexa_state_report.async_send_add_or_update_message(
self.hass, self, to_update
))
if to_remove:
tasks.append(alexa_state_report.async_send_delete_message(
self.hass, self, to_remove
))
try:
with async_timeout.timeout(10):
await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)
return True
except asyncio.TimeoutError:
_LOGGER.warning("Timeout trying to sync entitites to Alexa")
return False
except aiohttp.ClientError as err:
_LOGGER.warning("Error trying to sync entities to Alexa: %s", err)
return False
async def _handle_entity_registry_updated(self, event):
"""Handle when entity registry updated."""
if not self.enabled:
return
action = event.data['action']
entity_id = event.data['entity_id']
to_update = []
to_remove = []
if action == 'create' and self.should_expose(entity_id):
to_update.append(entity_id)
elif action == 'remove' and self.should_expose(entity_id):
to_remove.append(entity_id)
await self._sync_helper(to_update, to_remove)
class CloudClient(Interface):

View File

@ -32,6 +32,7 @@ CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
CONF_REMOTE_API_URL = 'remote_api_url'
CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server'
CONF_ALEXA_ACCESS_TOKEN_URL = 'alexa_access_token_url'
MODE_DEV = "development"
MODE_PROD = "production"

View File

@ -13,6 +13,7 @@ from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.data_validator import (
RequestDataValidator)
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import const as ws_const
from homeassistant.components.alexa import entities as alexa_entities
from homeassistant.components.google_assistant import helpers as google_helpers
@ -92,6 +93,7 @@ async def async_setup(hass):
hass.components.websocket_api.async_register_command(alexa_list)
hass.components.websocket_api.async_register_command(alexa_update)
hass.components.websocket_api.async_register_command(alexa_sync)
hass.http.register_view(GoogleActionsSyncView)
hass.http.register_view(CloudLoginView)
@ -560,3 +562,23 @@ async def alexa_update(hass, connection, msg):
connection.send_result(
msg['id'],
cloud.client.prefs.alexa_entity_configs.get(msg['entity_id']))
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.async_response
@websocket_api.websocket_command({
'type': 'cloud/alexa/sync',
})
async def alexa_sync(hass, connection, msg):
"""Sync with Alexa."""
cloud = hass.data[DOMAIN]
with async_timeout.timeout(10):
success = await cloud.client.alexa_config.async_sync_entities()
if success:
connection.send_result(msg['id'])
else:
connection.send_error(
msg['id'], ws_const.ERR_UNKNOWN_ERROR, 'Unknown error')

View File

@ -34,7 +34,63 @@ async def test_report_state(hass, aioclient_mock):
call = aioclient_mock.mock_calls
call_json = call[0][2]
assert call_json["event"]["header"]["namespace"] == "Alexa"
assert call_json["event"]["header"]["name"] == "ChangeReport"
assert call_json["event"]["payload"]["change"]["properties"][0]["value"] \
== "NOT_DETECTED"
assert call_json["event"]["endpoint"]["endpointId"] \
== "binary_sensor#test_contact"
async def test_send_add_or_update_message(hass, aioclient_mock):
"""Test sending an AddOrUpdateReport message."""
aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'})
hass.states.async_set(
'binary_sensor.test_contact',
'on',
{
'friendly_name': "Test Contact Sensor",
'device_class': 'door',
}
)
await state_report.async_send_add_or_update_message(
hass, DEFAULT_CONFIG, ['binary_sensor.test_contact'])
assert len(aioclient_mock.mock_calls) == 1
call = aioclient_mock.mock_calls
call_json = call[0][2]
assert call_json["event"]["header"]["namespace"] == "Alexa.Discovery"
assert call_json["event"]["header"]["name"] == "AddOrUpdateReport"
assert len(call_json["event"]["payload"]["endpoints"]) == 1
assert call_json["event"]["payload"]["endpoints"][0]["endpointId"] \
== "binary_sensor#test_contact"
async def test_send_delete_message(hass, aioclient_mock):
"""Test sending an AddOrUpdateReport message."""
aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'})
hass.states.async_set(
'binary_sensor.test_contact',
'on',
{
'friendly_name': "Test Contact Sensor",
'device_class': 'door',
}
)
await state_report.async_send_delete_message(
hass, DEFAULT_CONFIG, ['binary_sensor.test_contact'])
assert len(aioclient_mock.mock_calls) == 1
call = aioclient_mock.mock_calls
call_json = call[0][2]
assert call_json["event"]["header"]["namespace"] == "Alexa.Discovery"
assert call_json["event"]["header"]["name"] == "DeleteReport"
assert len(call_json["event"]["payload"]["endpoints"]) == 1
assert call_json["event"]["payload"]["endpoints"][0]["endpointId"] \
== "binary_sensor#test_contact"

View File

@ -1,4 +1,5 @@
"""Test the cloud.iot module."""
import contextlib
from unittest.mock import patch, MagicMock
from aiohttp import web
@ -11,8 +12,10 @@ from homeassistant.components.cloud import (
DOMAIN, ALEXA_SCHEMA, client)
from homeassistant.components.cloud.const import (
PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE)
from homeassistant.util.dt import utcnow
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from tests.components.alexa import test_smart_home as test_alexa
from tests.common import mock_coro
from tests.common import mock_coro, async_fire_time_changed
from . import mock_cloud_prefs
@ -292,3 +295,93 @@ async def test_alexa_config_report_state(hass, cloud_prefs):
assert cloud_prefs.alexa_report_state is False
assert conf.should_report_state is False
assert conf.is_reporting_states is False
@contextlib.contextmanager
def patch_sync_helper():
"""Patch sync helper.
In Py3.7 this would have been an async context manager.
"""
to_update = []
to_remove = []
with patch(
'homeassistant.components.cloud.client.SYNC_DELAY', 0
), patch(
'homeassistant.components.cloud.client.AlexaConfig._sync_helper',
side_effect=mock_coro
) as mock_helper:
yield to_update, to_remove
actual_to_update, actual_to_remove = mock_helper.mock_calls[0][1]
to_update.extend(actual_to_update)
to_remove.extend(actual_to_remove)
async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs):
"""Test Alexa config responds to updating exposed entities."""
client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None)
with patch_sync_helper() as (to_update, to_remove):
await cloud_prefs.async_update_alexa_entity_config(
entity_id='light.kitchen', should_expose=True
)
await hass.async_block_till_done()
async_fire_time_changed(hass, utcnow())
await hass.async_block_till_done()
assert to_update == ['light.kitchen']
assert to_remove == []
with patch_sync_helper() as (to_update, to_remove):
await cloud_prefs.async_update_alexa_entity_config(
entity_id='light.kitchen', should_expose=False
)
await cloud_prefs.async_update_alexa_entity_config(
entity_id='binary_sensor.door', should_expose=True
)
await cloud_prefs.async_update_alexa_entity_config(
entity_id='sensor.temp', should_expose=True
)
await hass.async_block_till_done()
async_fire_time_changed(hass, utcnow())
await hass.async_block_till_done()
assert sorted(to_update) == ['binary_sensor.door', 'sensor.temp']
assert to_remove == ['light.kitchen']
async def test_alexa_entity_registry_sync(hass, cloud_prefs):
"""Test Alexa config responds to entity registry."""
client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None)
with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, {
'action': 'create',
'entity_id': 'light.kitchen',
})
await hass.async_block_till_done()
assert to_update == ['light.kitchen']
assert to_remove == []
with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, {
'action': 'remove',
'entity_id': 'light.kitchen',
})
await hass.async_block_till_done()
assert to_update == []
assert to_remove == ['light.kitchen']
with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, {
'action': 'update',
'entity_id': 'light.kitchen',
})
await hass.async_block_till_done()
assert to_update == []
assert to_remove == []