Notify Alexa when exposed entities change (#24609)
parent
a89c8eeabe
commit
6d9f1b3fd3
|
@ -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]:
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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)
|
||||
]
|
||||
|
|
|
@ -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](
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 == []
|
||||
|
|
Loading…
Reference in New Issue