Notify Alexa when exposed entities change (#24609)
This commit is contained in:
parent
a89c8eeabe
commit
6d9f1b3fd3
12 changed files with 436 additions and 68 deletions
|
@ -168,6 +168,20 @@ class AlexaEntity:
|
||||||
for prop in interface.serialize_properties():
|
for prop in interface.serialize_properties():
|
||||||
yield prop
|
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
|
@callback
|
||||||
def async_get_entities(hass, config) -> List[AlexaEntity]:
|
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."""
|
"""This entity does not support the requested Smart Home API property."""
|
||||||
|
|
||||||
|
|
||||||
|
class NoTokenAvailable(HomeAssistantError):
|
||||||
|
"""There is no access token available."""
|
||||||
|
|
||||||
|
|
||||||
class AlexaError(Exception):
|
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.
|
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.
|
Async friendly.
|
||||||
"""
|
"""
|
||||||
discovery_endpoints = [
|
discovery_endpoints = [
|
||||||
{
|
alexa_entity.serialize_discovery()
|
||||||
'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()
|
|
||||||
]
|
|
||||||
}
|
|
||||||
for alexa_entity in async_get_entities(hass, config)
|
for alexa_entity in async_get_entities(hass, config)
|
||||||
if config.should_expose(alexa_entity.entity_id)
|
if config.should_expose(alexa_entity.entity_id)
|
||||||
]
|
]
|
||||||
|
|
|
@ -48,7 +48,7 @@ class AlexaDirective:
|
||||||
self.entity_id = _endpoint_id.replace('#', '.')
|
self.entity_id = _endpoint_id.replace('#', '.')
|
||||||
|
|
||||||
self.entity = hass.states.get(self.entity_id)
|
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)
|
raise AlexaInvalidEndpointError(_endpoint_id)
|
||||||
|
|
||||||
self.endpoint = ENTITY_ADAPTERS[self.entity.domain](
|
self.endpoint = ENTITY_ADAPTERS[self.entity.domain](
|
||||||
|
|
|
@ -16,41 +16,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
EVENT_ALEXA_SMART_HOME = 'alexa_smart_home'
|
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(
|
async def async_handle_message(
|
||||||
hass,
|
hass,
|
||||||
config,
|
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.
|
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,
|
async def async_entity_state_listener(changed_entity, old_state,
|
||||||
new_state):
|
new_state):
|
||||||
if not 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):
|
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()
|
token = await config.async_get_access_token()
|
||||||
if not token:
|
|
||||||
_LOGGER.error("Invalid access token.")
|
|
||||||
return
|
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": "Bearer {}".format(token)
|
"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.set_endpoint_full(token, endpoint)
|
||||||
|
|
||||||
message_serialized = message.serialize()
|
message_serialized = message.serialize()
|
||||||
|
session = hass.helpers.aiohttp_client.async_get_clientsession()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = hass.helpers.aiohttp_client.async_get_clientsession()
|
|
||||||
with async_timeout.timeout(DEFAULT_TIMEOUT):
|
with async_timeout.timeout(DEFAULT_TIMEOUT):
|
||||||
response = await session.post(config.endpoint,
|
response = await session.post(config.endpoint,
|
||||||
headers=headers,
|
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",
|
_LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s",
|
||||||
response_json["payload"]["code"],
|
response_json["payload"]["code"],
|
||||||
response_json["payload"]["description"])
|
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_CLOUDHOOK_CREATE_URL, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG,
|
||||||
CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL,
|
CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL,
|
||||||
CONF_RELAYER, CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_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
|
from .prefs import CloudPreferences
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -72,6 +73,7 @@ CONFIG_SCHEMA = vol.Schema({
|
||||||
vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(),
|
vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(),
|
||||||
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
|
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
|
||||||
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
|
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
|
||||||
|
vol.Optional(CONF_ALEXA_ACCESS_TOKEN_URL): str,
|
||||||
}),
|
}),
|
||||||
}, extra=vol.ALLOW_EXTRA)
|
}, extra=vol.ALLOW_EXTRA)
|
||||||
|
|
||||||
|
|
|
@ -6,19 +6,25 @@ from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import async_timeout
|
||||||
from hass_nabucasa import cloud_api
|
from hass_nabucasa import cloud_api
|
||||||
from hass_nabucasa.client import CloudClient as Interface
|
from hass_nabucasa.client import CloudClient as Interface
|
||||||
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.components.alexa import (
|
from homeassistant.components.alexa import (
|
||||||
config as alexa_config,
|
config as alexa_config,
|
||||||
|
errors as alexa_errors,
|
||||||
smart_home as alexa_sh,
|
smart_home as alexa_sh,
|
||||||
|
entities as alexa_entities,
|
||||||
|
state_report as alexa_state_report,
|
||||||
)
|
)
|
||||||
from homeassistant.components.google_assistant import (
|
from homeassistant.components.google_assistant import (
|
||||||
helpers as ga_h, smart_home as ga)
|
helpers as ga_h, smart_home as ga)
|
||||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
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.typing import HomeAssistantType
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
from homeassistant.helpers import entity_registry
|
||||||
from homeassistant.util.aiohttp import MockRequest
|
from homeassistant.util.aiohttp import MockRequest
|
||||||
from homeassistant.util.dt import utcnow
|
from homeassistant.util.dt import utcnow
|
||||||
|
|
||||||
|
@ -31,6 +37,9 @@ from .prefs import CloudPreferences
|
||||||
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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):
|
class AlexaConfig(alexa_config.AbstractConfig):
|
||||||
|
@ -44,7 +53,20 @@ class AlexaConfig(alexa_config.AbstractConfig):
|
||||||
self._cloud = cloud
|
self._cloud = cloud
|
||||||
self._token = None
|
self._token = None
|
||||||
self._token_valid = 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
|
@property
|
||||||
def supports_auth(self):
|
def supports_auth(self):
|
||||||
|
@ -59,7 +81,10 @@ class AlexaConfig(alexa_config.AbstractConfig):
|
||||||
@property
|
@property
|
||||||
def endpoint(self):
|
def endpoint(self):
|
||||||
"""Endpoint for report state."""
|
"""Endpoint for report state."""
|
||||||
return None
|
if self._endpoint is None:
|
||||||
|
raise ValueError("No endpoint available. Fetch access token first")
|
||||||
|
|
||||||
|
return self._endpoint
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def entity_config(self):
|
def entity_config(self):
|
||||||
|
@ -91,22 +116,144 @@ class AlexaConfig(alexa_config.AbstractConfig):
|
||||||
if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'):
|
if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'):
|
||||||
raise RequireRelink
|
raise RequireRelink
|
||||||
|
|
||||||
return None
|
return alexa_errors.NoTokenAvailable
|
||||||
|
|
||||||
self._token = body['access_token']
|
self._token = body['access_token']
|
||||||
|
self._endpoint = body['event_endpoint']
|
||||||
self._token_valid = utcnow() + timedelta(seconds=body['expires_in'])
|
self._token_valid = utcnow() + timedelta(seconds=body['expires_in'])
|
||||||
return self._token
|
return self._token
|
||||||
|
|
||||||
async def async_prefs_updated(self, prefs):
|
async def _async_prefs_updated(self, prefs):
|
||||||
"""Handle updated preferences."""
|
"""Handle updated preferences."""
|
||||||
if self.should_report_state == self.is_reporting_states:
|
if self.should_report_state != self.is_reporting_states:
|
||||||
return
|
|
||||||
|
|
||||||
if self.should_report_state:
|
if self.should_report_state:
|
||||||
await self.async_enable_proactive_mode()
|
await self.async_enable_proactive_mode()
|
||||||
else:
|
else:
|
||||||
await self.async_disable_proactive_mode()
|
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._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):
|
class CloudClient(Interface):
|
||||||
"""Interface class for Home Assistant Cloud."""
|
"""Interface class for Home Assistant Cloud."""
|
||||||
|
|
|
@ -32,6 +32,7 @@ CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
|
||||||
CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
|
CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
|
||||||
CONF_REMOTE_API_URL = 'remote_api_url'
|
CONF_REMOTE_API_URL = 'remote_api_url'
|
||||||
CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server'
|
CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server'
|
||||||
|
CONF_ALEXA_ACCESS_TOKEN_URL = 'alexa_access_token_url'
|
||||||
|
|
||||||
MODE_DEV = "development"
|
MODE_DEV = "development"
|
||||||
MODE_PROD = "production"
|
MODE_PROD = "production"
|
||||||
|
|
|
@ -13,6 +13,7 @@ from homeassistant.components.http import HomeAssistantView
|
||||||
from homeassistant.components.http.data_validator import (
|
from homeassistant.components.http.data_validator import (
|
||||||
RequestDataValidator)
|
RequestDataValidator)
|
||||||
from homeassistant.components import websocket_api
|
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.alexa import entities as alexa_entities
|
||||||
from homeassistant.components.google_assistant import helpers as google_helpers
|
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_list)
|
||||||
hass.components.websocket_api.async_register_command(alexa_update)
|
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(GoogleActionsSyncView)
|
||||||
hass.http.register_view(CloudLoginView)
|
hass.http.register_view(CloudLoginView)
|
||||||
|
@ -560,3 +562,23 @@ async def alexa_update(hass, connection, msg):
|
||||||
connection.send_result(
|
connection.send_result(
|
||||||
msg['id'],
|
msg['id'],
|
||||||
cloud.client.prefs.alexa_entity_configs.get(msg['entity_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 = aioclient_mock.mock_calls
|
||||||
|
|
||||||
call_json = call[0][2]
|
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"] \
|
assert call_json["event"]["payload"]["change"]["properties"][0]["value"] \
|
||||||
== "NOT_DETECTED"
|
== "NOT_DETECTED"
|
||||||
assert call_json["event"]["endpoint"]["endpointId"] \
|
assert call_json["event"]["endpoint"]["endpointId"] \
|
||||||
== "binary_sensor#test_contact"
|
== "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."""
|
"""Test the cloud.iot module."""
|
||||||
|
import contextlib
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
@ -11,8 +12,10 @@ from homeassistant.components.cloud import (
|
||||||
DOMAIN, ALEXA_SCHEMA, client)
|
DOMAIN, ALEXA_SCHEMA, client)
|
||||||
from homeassistant.components.cloud.const import (
|
from homeassistant.components.cloud.const import (
|
||||||
PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE)
|
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.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
|
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 cloud_prefs.alexa_report_state is False
|
||||||
assert conf.should_report_state is False
|
assert conf.should_report_state is False
|
||||||
assert conf.is_reporting_states 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…
Add table
Add a link
Reference in a new issue