parent
0f5c9b4af3
commit
327fe63047
|
@ -0,0 +1,244 @@
|
|||
"""Alexa configuration for Home Assistant Cloud."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
from hass_nabucasa import cloud_api
|
||||
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.components.alexa import (
|
||||
config as alexa_config,
|
||||
errors as alexa_errors,
|
||||
entities as alexa_entities,
|
||||
state_report as alexa_state_report,
|
||||
)
|
||||
|
||||
|
||||
from .const import (
|
||||
CONF_ENTITY_CONFIG, CONF_FILTER, PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE,
|
||||
RequireRelink
|
||||
)
|
||||
|
||||
_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):
|
||||
"""Alexa Configuration."""
|
||||
|
||||
def __init__(self, hass, config, prefs, cloud):
|
||||
"""Initialize the Alexa config."""
|
||||
super().__init__(hass)
|
||||
self._config = config
|
||||
self._prefs = prefs
|
||||
self._cloud = cloud
|
||||
self._token = None
|
||||
self._token_valid = None
|
||||
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):
|
||||
"""Return if config supports auth."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def should_report_state(self):
|
||||
"""Return if states should be proactively reported."""
|
||||
return self._prefs.alexa_report_state
|
||||
|
||||
@property
|
||||
def endpoint(self):
|
||||
"""Endpoint for report state."""
|
||||
if self._endpoint is None:
|
||||
raise ValueError("No endpoint available. Fetch access token first")
|
||||
|
||||
return self._endpoint
|
||||
|
||||
@property
|
||||
def entity_config(self):
|
||||
"""Return entity config."""
|
||||
return self._config.get(CONF_ENTITY_CONFIG, {})
|
||||
|
||||
def should_expose(self, entity_id):
|
||||
"""If an entity should be exposed."""
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
if not self._config[CONF_FILTER].empty_filter:
|
||||
return self._config[CONF_FILTER](entity_id)
|
||||
|
||||
entity_configs = self._prefs.alexa_entity_configs
|
||||
entity_config = entity_configs.get(entity_id, {})
|
||||
return entity_config.get(
|
||||
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
|
||||
|
||||
async def async_get_access_token(self):
|
||||
"""Get an access token."""
|
||||
if self._token_valid is not None and self._token_valid < utcnow():
|
||||
return self._token
|
||||
|
||||
resp = await cloud_api.async_alexa_access_token(self._cloud)
|
||||
body = await resp.json()
|
||||
|
||||
if resp.status == 400:
|
||||
if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'):
|
||||
raise RequireRelink
|
||||
|
||||
raise 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):
|
||||
"""Handle updated preferences."""
|
||||
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._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 or not self._cloud.is_logged_in:
|
||||
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)
|
|
@ -2,257 +2,24 @@
|
|||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
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.components.google_assistant import smart_home as ga
|
||||
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
|
||||
from homeassistant.components.alexa import smart_home as alexa_sh
|
||||
|
||||
from . import utils
|
||||
from .const import (
|
||||
CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE,
|
||||
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE,
|
||||
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA, RequireRelink)
|
||||
from . import utils, alexa_config, google_config
|
||||
from .const import DISPATCHER_REMOTE_UPDATE
|
||||
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):
|
||||
"""Alexa Configuration."""
|
||||
|
||||
def __init__(self, hass, config, prefs, cloud):
|
||||
"""Initialize the Alexa config."""
|
||||
super().__init__(hass)
|
||||
self._config = config
|
||||
self._prefs = prefs
|
||||
self._cloud = cloud
|
||||
self._token = None
|
||||
self._token_valid = None
|
||||
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):
|
||||
"""Return if config supports auth."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def should_report_state(self):
|
||||
"""Return if states should be proactively reported."""
|
||||
return self._prefs.alexa_report_state
|
||||
|
||||
@property
|
||||
def endpoint(self):
|
||||
"""Endpoint for report state."""
|
||||
if self._endpoint is None:
|
||||
raise ValueError("No endpoint available. Fetch access token first")
|
||||
|
||||
return self._endpoint
|
||||
|
||||
@property
|
||||
def entity_config(self):
|
||||
"""Return entity config."""
|
||||
return self._config.get(CONF_ENTITY_CONFIG, {})
|
||||
|
||||
def should_expose(self, entity_id):
|
||||
"""If an entity should be exposed."""
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
if not self._config[CONF_FILTER].empty_filter:
|
||||
return self._config[CONF_FILTER](entity_id)
|
||||
|
||||
entity_configs = self._prefs.alexa_entity_configs
|
||||
entity_config = entity_configs.get(entity_id, {})
|
||||
return entity_config.get(
|
||||
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
|
||||
|
||||
async def async_get_access_token(self):
|
||||
"""Get an access token."""
|
||||
if self._token_valid is not None and self._token_valid < utcnow():
|
||||
return self._token
|
||||
|
||||
resp = await cloud_api.async_alexa_access_token(self._cloud)
|
||||
body = await resp.json()
|
||||
|
||||
if resp.status == 400:
|
||||
if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'):
|
||||
raise RequireRelink
|
||||
|
||||
raise 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):
|
||||
"""Handle updated preferences."""
|
||||
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._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 or not self._cloud.is_logged_in:
|
||||
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):
|
||||
|
@ -260,13 +27,14 @@ class CloudClient(Interface):
|
|||
|
||||
def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences,
|
||||
websession: aiohttp.ClientSession,
|
||||
alexa_cfg: Dict[str, Any], google_config: Dict[str, Any]):
|
||||
alexa_user_config: Dict[str, Any],
|
||||
google_user_config: Dict[str, Any]):
|
||||
"""Initialize client interface to Cloud."""
|
||||
self._hass = hass
|
||||
self._prefs = prefs
|
||||
self._websession = websession
|
||||
self.google_user_config = google_config
|
||||
self.alexa_user_config = alexa_cfg
|
||||
self.google_user_config = google_user_config
|
||||
self.alexa_user_config = alexa_user_config
|
||||
self._alexa_config = None
|
||||
self._google_config = None
|
||||
self.cloud = None
|
||||
|
@ -307,53 +75,22 @@ class CloudClient(Interface):
|
|||
return self._prefs.remote_enabled
|
||||
|
||||
@property
|
||||
def alexa_config(self) -> AlexaConfig:
|
||||
def alexa_config(self) -> alexa_config.AlexaConfig:
|
||||
"""Return Alexa config."""
|
||||
if self._alexa_config is None:
|
||||
self._alexa_config = AlexaConfig(
|
||||
assert self.cloud is not None
|
||||
self._alexa_config = alexa_config.AlexaConfig(
|
||||
self._hass, self.alexa_user_config, self._prefs, self.cloud)
|
||||
|
||||
return self._alexa_config
|
||||
|
||||
@property
|
||||
def google_config(self) -> ga_h.Config:
|
||||
def google_config(self) -> google_config.CloudGoogleConfig:
|
||||
"""Return Google config."""
|
||||
if not self._google_config:
|
||||
google_conf = self.google_user_config
|
||||
|
||||
def should_expose(entity):
|
||||
"""If an entity should be exposed."""
|
||||
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
if not google_conf['filter'].empty_filter:
|
||||
return google_conf['filter'](entity.entity_id)
|
||||
|
||||
entity_configs = self.prefs.google_entity_configs
|
||||
entity_config = entity_configs.get(entity.entity_id, {})
|
||||
return entity_config.get(
|
||||
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
|
||||
|
||||
def should_2fa(entity):
|
||||
"""If an entity should be checked for 2FA."""
|
||||
entity_configs = self.prefs.google_entity_configs
|
||||
entity_config = entity_configs.get(entity.entity_id, {})
|
||||
return not entity_config.get(
|
||||
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
|
||||
|
||||
username = self._hass.data[DOMAIN].claims["cognito:username"]
|
||||
|
||||
self._google_config = ga_h.Config(
|
||||
should_expose=should_expose,
|
||||
should_2fa=should_2fa,
|
||||
secure_devices_pin=self._prefs.google_secure_devices_pin,
|
||||
entity_config=google_conf.get(CONF_ENTITY_CONFIG),
|
||||
agent_user_id=username,
|
||||
)
|
||||
|
||||
# Set it to the latest.
|
||||
self._google_config.secure_devices_pin = \
|
||||
self._prefs.google_secure_devices_pin
|
||||
assert self.cloud is not None
|
||||
self._google_config = google_config.CloudGoogleConfig(
|
||||
self.google_user_config, self._prefs, self.cloud)
|
||||
|
||||
return self._google_config
|
||||
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
"""Google config for Cloud."""
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.components.google_assistant.helpers import AbstractConfig
|
||||
|
||||
from .const import (
|
||||
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE, CONF_ENTITY_CONFIG,
|
||||
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
|
||||
|
||||
|
||||
class CloudGoogleConfig(AbstractConfig):
|
||||
"""HA Cloud Configuration for Google Assistant."""
|
||||
|
||||
def __init__(self, config, prefs, cloud):
|
||||
"""Initialize the Alexa config."""
|
||||
self._config = config
|
||||
self._prefs = prefs
|
||||
self._cloud = cloud
|
||||
|
||||
@property
|
||||
def agent_user_id(self):
|
||||
"""Return Agent User Id to use for query responses."""
|
||||
return self._cloud.claims["cognito:username"]
|
||||
|
||||
@property
|
||||
def entity_config(self):
|
||||
"""Return entity config."""
|
||||
return self._config.get(CONF_ENTITY_CONFIG)
|
||||
|
||||
@property
|
||||
def secure_devices_pin(self):
|
||||
"""Return entity config."""
|
||||
return self._prefs.google_secure_devices_pin
|
||||
|
||||
def should_expose(self, state):
|
||||
"""If an entity should be exposed."""
|
||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
if not self._config['filter'].empty_filter:
|
||||
return self._config['filter'](state.entity_id)
|
||||
|
||||
entity_configs = self._prefs.google_entity_configs
|
||||
entity_config = entity_configs.get(state.entity_id, {})
|
||||
return entity_config.get(
|
||||
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
|
||||
|
||||
def should_2fa(self, state):
|
||||
"""If an entity should be checked for 2FA."""
|
||||
entity_configs = self._prefs.google_entity_configs
|
||||
entity_config = entity_configs.get(state.entity_id, {})
|
||||
return not entity_config.get(
|
||||
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
|
|
@ -17,24 +17,32 @@ from .const import (
|
|||
from .error import SmartHomeError
|
||||
|
||||
|
||||
class Config:
|
||||
class AbstractConfig:
|
||||
"""Hold the configuration for Google Assistant."""
|
||||
|
||||
def __init__(self, should_expose,
|
||||
entity_config=None, secure_devices_pin=None,
|
||||
agent_user_id=None, should_2fa=None):
|
||||
"""Initialize the configuration."""
|
||||
self.should_expose = should_expose
|
||||
self.entity_config = entity_config or {}
|
||||
self.secure_devices_pin = secure_devices_pin
|
||||
self._should_2fa = should_2fa
|
||||
@property
|
||||
def agent_user_id(self):
|
||||
"""Return Agent User Id to use for query responses."""
|
||||
return None
|
||||
|
||||
# Agent User Id to use for query responses
|
||||
self.agent_user_id = agent_user_id
|
||||
@property
|
||||
def entity_config(self):
|
||||
"""Return entity config."""
|
||||
return {}
|
||||
|
||||
@property
|
||||
def secure_devices_pin(self):
|
||||
"""Return entity config."""
|
||||
return None
|
||||
|
||||
def should_expose(self, state) -> bool:
|
||||
"""Return if entity should be exposed."""
|
||||
raise NotImplementedError
|
||||
|
||||
def should_2fa(self, state):
|
||||
"""If an entity should have 2FA checked."""
|
||||
return self._should_2fa is None or self._should_2fa(state)
|
||||
# pylint: disable=no-self-use
|
||||
return True
|
||||
|
||||
|
||||
class RequestData:
|
||||
|
|
|
@ -17,33 +17,50 @@ from .const import (
|
|||
CONF_SECURE_DEVICES_PIN,
|
||||
)
|
||||
from .smart_home import async_handle_message
|
||||
from .helpers import Config
|
||||
from .helpers import AbstractConfig
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_http(hass, cfg):
|
||||
"""Register HTTP views for Google Assistant."""
|
||||
expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT)
|
||||
exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS)
|
||||
entity_config = cfg.get(CONF_ENTITY_CONFIG) or {}
|
||||
secure_devices_pin = cfg.get(CONF_SECURE_DEVICES_PIN)
|
||||
class GoogleConfig(AbstractConfig):
|
||||
"""Config for manual setup of Google."""
|
||||
|
||||
def is_exposed(entity) -> bool:
|
||||
"""Determine if an entity should be exposed to Google Assistant."""
|
||||
if entity.attributes.get('view') is not None:
|
||||
def __init__(self, config):
|
||||
"""Initialize the config."""
|
||||
self._config = config
|
||||
|
||||
@property
|
||||
def agent_user_id(self):
|
||||
"""Return Agent User Id to use for query responses."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def entity_config(self):
|
||||
"""Return entity config."""
|
||||
return self._config.get(CONF_ENTITY_CONFIG, {})
|
||||
|
||||
@property
|
||||
def secure_devices_pin(self):
|
||||
"""Return entity config."""
|
||||
return self._config.get(CONF_SECURE_DEVICES_PIN)
|
||||
|
||||
def should_expose(self, state) -> bool:
|
||||
"""Return if entity should be exposed."""
|
||||
expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT)
|
||||
exposed_domains = self._config.get(CONF_EXPOSED_DOMAINS)
|
||||
|
||||
if state.attributes.get('view') is not None:
|
||||
# Ignore entities that are views
|
||||
return False
|
||||
|
||||
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
explicit_expose = \
|
||||
entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE)
|
||||
self.entity_config.get(state.entity_id, {}).get(CONF_EXPOSE)
|
||||
|
||||
domain_exposed_by_default = \
|
||||
expose_by_default and entity.domain in exposed_domains
|
||||
expose_by_default and state.domain in exposed_domains
|
||||
|
||||
# Expose an entity if the entity's domain is exposed by default and
|
||||
# the configuration doesn't explicitly exclude it from being
|
||||
|
@ -53,13 +70,15 @@ def async_register_http(hass, cfg):
|
|||
|
||||
return is_default_exposed or explicit_expose
|
||||
|
||||
config = Config(
|
||||
should_expose=is_exposed,
|
||||
entity_config=entity_config,
|
||||
secure_devices_pin=secure_devices_pin
|
||||
)
|
||||
def should_2fa(self, state):
|
||||
"""If an entity should have 2FA checked."""
|
||||
return True
|
||||
|
||||
hass.http.register_view(GoogleAssistantView(config))
|
||||
|
||||
@callback
|
||||
def async_register_http(hass, cfg):
|
||||
"""Register HTTP views for Google Assistant."""
|
||||
hass.http.register_view(GoogleAssistantView(GoogleConfig(cfg)))
|
||||
|
||||
|
||||
class GoogleAssistantView(HomeAssistantView):
|
||||
|
|
|
@ -1,24 +1,22 @@
|
|||
"""Tests for the cloud component."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.components import cloud
|
||||
from homeassistant.components.cloud import const
|
||||
|
||||
from jose import jwt
|
||||
|
||||
from tests.common import mock_coro
|
||||
|
||||
|
||||
def mock_cloud(hass, config={}):
|
||||
async def mock_cloud(hass, config=None):
|
||||
"""Mock cloud."""
|
||||
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
|
||||
assert hass.loop.run_until_complete(async_setup_component(
|
||||
hass, cloud.DOMAIN, {
|
||||
'cloud': config
|
||||
}))
|
||||
|
||||
hass.data[cloud.DOMAIN]._decode_claims = \
|
||||
lambda token: jwt.get_unverified_claims(token)
|
||||
assert await async_setup_component(
|
||||
hass, cloud.DOMAIN, {
|
||||
'cloud': config or {}
|
||||
})
|
||||
cloud_inst = hass.data['cloud']
|
||||
with patch('hass_nabucasa.Cloud.run_executor', return_value=mock_coro()):
|
||||
await cloud_inst.start()
|
||||
|
||||
|
||||
def mock_cloud_prefs(hass, prefs={}):
|
||||
|
|
|
@ -18,7 +18,7 @@ def mock_user_data():
|
|||
@pytest.fixture
|
||||
def mock_cloud_fixture(hass):
|
||||
"""Fixture for cloud component."""
|
||||
mock_cloud(hass)
|
||||
hass.loop.run_until_complete(mock_cloud(hass))
|
||||
return mock_cloud_prefs(hass)
|
||||
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import pytest
|
|||
from homeassistant.core import State
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.components.cloud import (
|
||||
DOMAIN, ALEXA_SCHEMA, client)
|
||||
DOMAIN, ALEXA_SCHEMA, alexa_config)
|
||||
from homeassistant.components.cloud.const import (
|
||||
PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE)
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
@ -17,11 +17,11 @@ 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, async_fire_time_changed
|
||||
|
||||
from . import mock_cloud_prefs
|
||||
from . import mock_cloud_prefs, mock_cloud
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cloud():
|
||||
def mock_cloud_inst():
|
||||
"""Mock cloud class."""
|
||||
return MagicMock(subscription_expired=False)
|
||||
|
||||
|
@ -29,10 +29,7 @@ def mock_cloud():
|
|||
@pytest.fixture
|
||||
async def mock_cloud_setup(hass):
|
||||
"""Set up the cloud."""
|
||||
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
|
||||
assert await async_setup_component(hass, 'cloud', {
|
||||
'cloud': {}
|
||||
})
|
||||
await mock_cloud(hass)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
@ -52,24 +49,20 @@ async def test_handler_alexa(hass):
|
|||
hass.states.async_set(
|
||||
'switch.test2', 'on', {'friendly_name': "Test switch 2"})
|
||||
|
||||
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
|
||||
setup = await async_setup_component(hass, 'cloud', {
|
||||
'cloud': {
|
||||
'alexa': {
|
||||
'filter': {
|
||||
'exclude_entities': 'switch.test2'
|
||||
},
|
||||
'entity_config': {
|
||||
'switch.test': {
|
||||
'name': 'Config name',
|
||||
'description': 'Config description',
|
||||
'display_categories': 'LIGHT'
|
||||
}
|
||||
}
|
||||
await mock_cloud(hass, {
|
||||
'alexa': {
|
||||
'filter': {
|
||||
'exclude_entities': 'switch.test2'
|
||||
},
|
||||
'entity_config': {
|
||||
'switch.test': {
|
||||
'name': 'Config name',
|
||||
'description': 'Config description',
|
||||
'display_categories': 'LIGHT'
|
||||
}
|
||||
}
|
||||
})
|
||||
assert setup
|
||||
}
|
||||
})
|
||||
|
||||
mock_cloud_prefs(hass)
|
||||
cloud = hass.data['cloud']
|
||||
|
@ -110,24 +103,20 @@ async def test_handler_google_actions(hass):
|
|||
hass.states.async_set(
|
||||
'group.all_locks', 'on', {'friendly_name': "Evil locks"})
|
||||
|
||||
with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
|
||||
setup = await async_setup_component(hass, 'cloud', {
|
||||
'cloud': {
|
||||
'google_actions': {
|
||||
'filter': {
|
||||
'exclude_entities': 'switch.test2'
|
||||
},
|
||||
'entity_config': {
|
||||
'switch.test': {
|
||||
'name': 'Config name',
|
||||
'aliases': 'Config alias',
|
||||
'room': 'living room'
|
||||
}
|
||||
}
|
||||
await mock_cloud(hass, {
|
||||
'google_actions': {
|
||||
'filter': {
|
||||
'exclude_entities': 'switch.test2'
|
||||
},
|
||||
'entity_config': {
|
||||
'switch.test': {
|
||||
'name': 'Config name',
|
||||
'aliases': 'Config alias',
|
||||
'room': 'living room'
|
||||
}
|
||||
}
|
||||
})
|
||||
assert setup
|
||||
}
|
||||
})
|
||||
|
||||
mock_cloud_prefs(hass)
|
||||
cloud = hass.data['cloud']
|
||||
|
@ -265,7 +254,7 @@ async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs):
|
|||
await cloud_prefs.async_update(alexa_entity_configs={
|
||||
'light.kitchen': entity_conf
|
||||
})
|
||||
conf = client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None)
|
||||
conf = alexa_config.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None)
|
||||
|
||||
assert not conf.should_expose('light.kitchen')
|
||||
entity_conf['should_expose'] = True
|
||||
|
@ -274,7 +263,7 @@ async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs):
|
|||
|
||||
async def test_alexa_config_report_state(hass, cloud_prefs):
|
||||
"""Test Alexa config should expose using prefs."""
|
||||
conf = client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None)
|
||||
conf = alexa_config.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None)
|
||||
|
||||
assert cloud_prefs.alexa_report_state is False
|
||||
assert conf.should_report_state is False
|
||||
|
@ -307,9 +296,9 @@ def patch_sync_helper():
|
|||
to_remove = []
|
||||
|
||||
with patch(
|
||||
'homeassistant.components.cloud.client.SYNC_DELAY', 0
|
||||
'homeassistant.components.cloud.alexa_config.SYNC_DELAY', 0
|
||||
), patch(
|
||||
'homeassistant.components.cloud.client.AlexaConfig._sync_helper',
|
||||
'homeassistant.components.cloud.alexa_config.AlexaConfig._sync_helper',
|
||||
side_effect=mock_coro
|
||||
) as mock_helper:
|
||||
yield to_update, to_remove
|
||||
|
@ -321,7 +310,7 @@ def patch_sync_helper():
|
|||
|
||||
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)
|
||||
alexa_config.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None)
|
||||
|
||||
with patch_sync_helper() as (to_update, to_remove):
|
||||
await cloud_prefs.async_update_alexa_entity_config(
|
||||
|
@ -354,7 +343,8 @@ async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs):
|
|||
|
||||
async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs):
|
||||
"""Test Alexa config responds to entity registry."""
|
||||
client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, hass.data['cloud'])
|
||||
alexa_config.AlexaConfig(
|
||||
hass, ALEXA_SCHEMA({}), cloud_prefs, hass.data['cloud'])
|
||||
|
||||
with patch_sync_helper() as (to_update, to_remove):
|
||||
hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, {
|
||||
|
|
|
@ -14,10 +14,11 @@ from homeassistant.components.cloud.const import (
|
|||
PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_SECURE_DEVICES_PIN,
|
||||
DOMAIN)
|
||||
from homeassistant.components.google_assistant.helpers import (
|
||||
GoogleEntity, Config)
|
||||
GoogleEntity)
|
||||
from homeassistant.components.alexa.entities import LightCapabilities
|
||||
|
||||
from tests.common import mock_coro
|
||||
from tests.components.google_assistant import MockConfig
|
||||
|
||||
from . import mock_cloud, mock_cloud_prefs
|
||||
|
||||
|
@ -45,7 +46,7 @@ def mock_cloud_login(hass, setup_api):
|
|||
@pytest.fixture(autouse=True)
|
||||
def setup_api(hass, aioclient_mock):
|
||||
"""Initialize HTTP API."""
|
||||
mock_cloud(hass, {
|
||||
hass.loop.run_until_complete(mock_cloud(hass, {
|
||||
'mode': 'development',
|
||||
'cognito_client_id': 'cognito_client_id',
|
||||
'user_pool_id': 'user_pool_id',
|
||||
|
@ -63,7 +64,7 @@ def setup_api(hass, aioclient_mock):
|
|||
'include_entities': ['light.kitchen', 'switch.ac']
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
return mock_cloud_prefs(hass)
|
||||
|
||||
|
||||
|
@ -709,9 +710,10 @@ async def test_list_google_entities(
|
|||
hass, hass_ws_client, setup_api, mock_cloud_login):
|
||||
"""Test that we can list Google entities."""
|
||||
client = await hass_ws_client(hass)
|
||||
entity = GoogleEntity(hass, Config(lambda *_: False), State(
|
||||
'light.kitchen', 'on'
|
||||
))
|
||||
entity = GoogleEntity(
|
||||
hass, MockConfig(should_expose=lambda *_: False), State(
|
||||
'light.kitchen', 'on'
|
||||
))
|
||||
with patch('homeassistant.components.google_assistant.helpers'
|
||||
'.async_get_entities', return_value=[entity]):
|
||||
await client.send_json({
|
||||
|
|
|
@ -1,6 +1,33 @@
|
|||
|
||||
|
||||
"""Tests for the Google Assistant integration."""
|
||||
from homeassistant.components.google_assistant import helpers
|
||||
|
||||
|
||||
class MockConfig(helpers.AbstractConfig):
|
||||
"""Fake config that always exposes everything."""
|
||||
|
||||
def __init__(self, *, secure_devices_pin=None, should_expose=None,
|
||||
entity_config=None):
|
||||
"""Initialize config."""
|
||||
self._should_expose = should_expose
|
||||
self._secure_devices_pin = secure_devices_pin
|
||||
self._entity_config = entity_config or {}
|
||||
|
||||
@property
|
||||
def secure_devices_pin(self):
|
||||
"""Return secure devices pin."""
|
||||
return self._secure_devices_pin
|
||||
|
||||
@property
|
||||
def entity_config(self):
|
||||
"""Return secure devices pin."""
|
||||
return self._entity_config
|
||||
|
||||
def should_expose(self, state):
|
||||
"""Expose it all."""
|
||||
return self._should_expose is None or self._should_expose(state)
|
||||
|
||||
|
||||
BASIC_CONFIG = MockConfig()
|
||||
|
||||
DEMO_DEVICES = [{
|
||||
'id':
|
||||
|
|
|
@ -11,7 +11,7 @@ from homeassistant.components.climate.const import (
|
|||
ATTR_MIN_TEMP, ATTR_MAX_TEMP, STATE_HEAT, SUPPORT_OPERATION_MODE
|
||||
)
|
||||
from homeassistant.components.google_assistant import (
|
||||
const, trait, helpers, smart_home as sh,
|
||||
const, trait, smart_home as sh,
|
||||
EVENT_COMMAND_RECEIVED, EVENT_QUERY_RECEIVED, EVENT_SYNC_RECEIVED)
|
||||
from homeassistant.components.demo.binary_sensor import DemoBinarySensor
|
||||
from homeassistant.components.demo.cover import DemoCover
|
||||
|
@ -23,9 +23,8 @@ from homeassistant.helpers import device_registry
|
|||
from tests.common import (mock_device_registry, mock_registry,
|
||||
mock_area_registry, mock_coro)
|
||||
|
||||
BASIC_CONFIG = helpers.Config(
|
||||
should_expose=lambda state: True,
|
||||
)
|
||||
from . import BASIC_CONFIG, MockConfig
|
||||
|
||||
REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf'
|
||||
|
||||
|
||||
|
@ -57,7 +56,7 @@ async def test_sync_message(hass):
|
|||
# Excluded via config
|
||||
hass.states.async_set('light.not_expose', 'on')
|
||||
|
||||
config = helpers.Config(
|
||||
config = MockConfig(
|
||||
should_expose=lambda state: state.entity_id != 'light.not_expose',
|
||||
entity_config={
|
||||
'light.demo_light': {
|
||||
|
@ -145,7 +144,7 @@ async def test_sync_in_area(hass, registries):
|
|||
light.entity_id = entity.entity_id
|
||||
await light.async_update_ha_state()
|
||||
|
||||
config = helpers.Config(
|
||||
config = MockConfig(
|
||||
should_expose=lambda _: True,
|
||||
entity_config={}
|
||||
)
|
||||
|
|
|
@ -29,10 +29,8 @@ from homeassistant.const import (
|
|||
from homeassistant.core import State, DOMAIN as HA_DOMAIN, EVENT_CALL_SERVICE
|
||||
from homeassistant.util import color
|
||||
from tests.common import async_mock_service, mock_coro
|
||||
from . import BASIC_CONFIG, MockConfig
|
||||
|
||||
BASIC_CONFIG = helpers.Config(
|
||||
should_expose=lambda state: True,
|
||||
)
|
||||
|
||||
REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf'
|
||||
|
||||
|
@ -42,8 +40,7 @@ BASIC_DATA = helpers.RequestData(
|
|||
REQ_ID,
|
||||
)
|
||||
|
||||
PIN_CONFIG = helpers.Config(
|
||||
should_expose=lambda state: True,
|
||||
PIN_CONFIG = MockConfig(
|
||||
secure_devices_pin='1234'
|
||||
)
|
||||
|
||||
|
@ -927,7 +924,7 @@ async def test_lock_unlock_unlock(hass):
|
|||
|
||||
# Test with 2FA override
|
||||
with patch('homeassistant.components.google_assistant.helpers'
|
||||
'.Config.should_2fa', return_value=False):
|
||||
'.AbstractConfig.should_2fa', return_value=False):
|
||||
await trt.execute(
|
||||
trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}, {})
|
||||
assert len(calls) == 2
|
||||
|
|
Loading…
Reference in New Issue