Cloud: Add Alexa report state (#24536)

* Cloud: Add Alexa report state

* Lint

* Lint

* Only track state changes when we are logged in
pull/24601/head
Paulus Schoutsen 2019-06-17 13:50:01 -07:00 committed by GitHub
parent 5ab1996d3f
commit a02b69db38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 207 additions and 33 deletions

View File

@ -1,14 +1,26 @@
"""Config helpers for Alexa."""
from .state_report import async_enable_proactive_mode
class AbstractConfig:
"""Hold the configuration for Alexa."""
_unsub_proactive_report = None
def __init__(self, hass):
"""Initialize abstract config."""
self.hass = hass
@property
def supports_auth(self):
"""Return if config supports auth."""
return False
@property
def should_report_state(self):
"""Return if states should be proactively reported."""
return False
@property
def endpoint(self):
"""Endpoint for report state."""
@ -19,6 +31,30 @@ class AbstractConfig:
"""Return entity config."""
return {}
@property
def is_reporting_states(self):
"""Return if proactive mode is enabled."""
return self._unsub_proactive_report is not None
async def async_enable_proactive_mode(self):
"""Enable proactive mode."""
if self._unsub_proactive_report is None:
self._unsub_proactive_report = self.hass.async_create_task(
async_enable_proactive_mode(self.hass, self)
)
resp = await self._unsub_proactive_report
# Failed to start reporting.
if resp is None:
self._unsub_proactive_report = None
async def async_disable_proactive_mode(self):
"""Disable proactive mode."""
unsub_func = await self._unsub_proactive_report
if unsub_func:
unsub_func()
self._unsub_proactive_report = None
def should_expose(self, entity_id):
"""If an entity should be exposed."""
# pylint: disable=no-self-use

View File

@ -87,7 +87,9 @@ async def async_api_accept_grant(hass, config, directive, context):
if config.supports_auth:
await config.async_accept_grant(auth_code)
await async_enable_proactive_mode(hass, config)
if config.should_report_state:
await async_enable_proactive_mode(hass, config)
return directive.response(
name='AcceptGrant.Response',

View File

@ -25,6 +25,7 @@ class AlexaConfig(AbstractConfig):
def __init__(self, hass, config):
"""Initialize Alexa config."""
super().__init__(hass)
self._config = config
if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET):
@ -38,6 +39,11 @@ class AlexaConfig(AbstractConfig):
"""Return if config supports auth."""
return self._auth is not None
@property
def should_report_state(self):
"""Return if we should proactively report states."""
return self._auth is not None
@property
def endpoint(self):
"""Endpoint for report state."""
@ -73,7 +79,7 @@ async def async_setup(hass, config):
smart_home_config = AlexaConfig(hass, config)
hass.http.register_view(SmartHomeView(smart_home_config))
if smart_home_config.supports_auth:
if smart_home_config.should_report_state:
await async_enable_proactive_mode(hass, smart_home_config)

View File

@ -21,24 +21,23 @@ async def async_enable_proactive_mode(hass, smart_home_config):
Proactive mode makes this component report state changes to Alexa.
"""
if smart_home_config.async_get_access_token is None:
# no function to call to get token
return
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 smart_home_config.should_expose(changed_entity):
_LOGGER.debug("Not exposing %s because filtered by config",
changed_entity)
if not new_state:
return
if new_state.domain not in ENTITY_ADAPTERS:
return
if not smart_home_config.should_expose(changed_entity):
_LOGGER.debug("Not exposing %s because filtered by config",
changed_entity)
return
alexa_changed_entity = \
ENTITY_ADAPTERS[new_state.domain](hass, smart_home_config,
new_state)
@ -49,7 +48,7 @@ async def async_enable_proactive_mode(hass, smart_home_config):
alexa_changed_entity)
return
hass.helpers.event.async_track_state_change(
return hass.helpers.event.async_track_state_change(
MATCH_ALL, async_entity_state_listener
)
@ -94,7 +93,7 @@ async def async_send_changereport_message(hass, config, alexa_entity):
allow_redirects=True)
except (asyncio.TimeoutError, aiohttp.ClientError):
_LOGGER.error("Timeout calling LWA to get auth token.")
_LOGGER.error("Timeout sending report to Alexa.")
return None
response_text = await response.text()

View File

@ -2,8 +2,11 @@
import asyncio
from pathlib import Path
from typing import Any, Dict
from datetime import timedelta
import logging
import aiohttp
from hass_nabucasa import cloud_api
from hass_nabucasa.client import CloudClient as Interface
from homeassistant.core import callback
@ -17,22 +20,41 @@ from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.util.aiohttp import MockRequest
from homeassistant.util.dt import utcnow
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)
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA, RequireRelink)
from .prefs import CloudPreferences
_LOGGER = logging.getLogger(__name__)
class AlexaConfig(alexa_config.AbstractConfig):
"""Alexa Configuration."""
def __init__(self, config, prefs):
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
prefs.async_listen_updates(self.async_prefs_updated)
@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):
@ -57,6 +79,34 @@ class AlexaConfig(alexa_config.AbstractConfig):
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
return None
self._token = body['access_token']
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:
return
if self.should_report_state:
await self.async_enable_proactive_mode()
else:
await self.async_disable_proactive_mode()
class CloudClient(Interface):
"""Interface class for Home Assistant Cloud."""
@ -70,9 +120,9 @@ class CloudClient(Interface):
self._websession = websession
self.google_user_config = google_config
self.alexa_user_config = alexa_cfg
self.alexa_config = AlexaConfig(alexa_cfg, prefs)
self._alexa_config = None
self._google_config = None
self.cloud = None
@property
def base_path(self) -> Path:
@ -109,6 +159,15 @@ class CloudClient(Interface):
"""Return true if we want start a remote connection."""
return self._prefs.remote_enabled
@property
def alexa_config(self) -> AlexaConfig:
"""Return Alexa config."""
if self._alexa_config is None:
self._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:
"""Return Google config."""
@ -151,6 +210,13 @@ class CloudClient(Interface):
return self._google_config
async def async_initialize(self, cloud) -> None:
"""Initialize the client."""
self.cloud = cloud
if self.alexa_config.should_report_state and self.cloud.is_logged_in:
await self.alexa_config.async_enable_proactive_mode()
async def cleanups(self) -> None:
"""Cleanup some stuff after logout."""
self._google_config = None

View File

@ -10,12 +10,14 @@ PREF_CLOUDHOOKS = 'cloudhooks'
PREF_CLOUD_USER = 'cloud_user'
PREF_GOOGLE_ENTITY_CONFIGS = 'google_entity_configs'
PREF_ALEXA_ENTITY_CONFIGS = 'alexa_entity_configs'
PREF_ALEXA_REPORT_STATE = 'alexa_report_state'
PREF_OVERRIDE_NAME = 'override_name'
PREF_DISABLE_2FA = 'disable_2fa'
PREF_ALIASES = 'aliases'
PREF_SHOULD_EXPOSE = 'should_expose'
DEFAULT_SHOULD_EXPOSE = True
DEFAULT_DISABLE_2FA = False
DEFAULT_ALEXA_REPORT_STATE = False
CONF_ALEXA = 'alexa'
CONF_ALIASES = 'aliases'
@ -43,3 +45,7 @@ class InvalidTrustedNetworks(Exception):
class InvalidTrustedProxies(Exception):
"""Raised when invalid trusted proxies config."""
class RequireRelink(Exception):
"""The skill needs to be relinked."""

View File

@ -19,7 +19,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers
from .const import (
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks,
InvalidTrustedProxies)
InvalidTrustedProxies, PREF_ALEXA_REPORT_STATE)
_LOGGER = logging.getLogger(__name__)
@ -363,6 +363,7 @@ async def websocket_subscription(hass, connection, msg):
vol.Required('type'): 'cloud/update_prefs',
vol.Optional(PREF_ENABLE_GOOGLE): bool,
vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
})
async def websocket_update_prefs(hass, connection, msg):
@ -424,7 +425,6 @@ def _account_data(cloud):
'prefs': client.prefs.as_dict(),
'google_entities': client.google_user_config['filter'].config,
'alexa_entities': client.alexa_user_config['filter'].config,
'alexa_domains': list(alexa_entities.ENTITY_ADAPTERS),
'remote_domain': remote.instance_domain,
'remote_connected': remote.is_connected,
'remote_certificate': certificate,

View File

@ -3,7 +3,7 @@
"name": "Cloud",
"documentation": "https://www.home-assistant.io/components/cloud",
"requirements": [
"hass-nabucasa==0.14"
"hass-nabucasa==0.15"
],
"dependencies": [
"http",

View File

@ -1,11 +1,15 @@
"""Preference management for cloud."""
from ipaddress import ip_address
from homeassistant.core import callback
from homeassistant.util.logging import async_create_catching_coro
from .const import (
DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE,
PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER,
PREF_GOOGLE_ENTITY_CONFIGS, PREF_OVERRIDE_NAME, PREF_DISABLE_2FA,
PREF_ALIASES, PREF_SHOULD_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS,
PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE,
InvalidTrustedNetworks, InvalidTrustedProxies)
STORAGE_KEY = DOMAIN
@ -21,6 +25,7 @@ class CloudPreferences:
self._hass = hass
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
self._prefs = None
self._listeners = []
async def async_initialize(self):
"""Finish initializing the preferences."""
@ -40,11 +45,17 @@ class CloudPreferences:
self._prefs = prefs
@callback
def async_listen_updates(self, listener):
"""Listen for updates to the preferences."""
self._listeners.append(listener)
async def async_update(self, *, google_enabled=_UNDEF,
alexa_enabled=_UNDEF, remote_enabled=_UNDEF,
google_secure_devices_pin=_UNDEF, cloudhooks=_UNDEF,
cloud_user=_UNDEF, google_entity_configs=_UNDEF,
alexa_entity_configs=_UNDEF):
alexa_entity_configs=_UNDEF,
alexa_report_state=_UNDEF):
"""Update user preferences."""
for key, value in (
(PREF_ENABLE_GOOGLE, google_enabled),
@ -55,18 +66,26 @@ class CloudPreferences:
(PREF_CLOUD_USER, cloud_user),
(PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs),
(PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs),
(PREF_ALEXA_REPORT_STATE, alexa_report_state),
):
if value is not _UNDEF:
self._prefs[key] = value
if remote_enabled is True and self._has_local_trusted_network:
self._prefs[PREF_ENABLE_REMOTE] = False
raise InvalidTrustedNetworks
if remote_enabled is True and self._has_local_trusted_proxies:
self._prefs[PREF_ENABLE_REMOTE] = False
raise InvalidTrustedProxies
await self._store.async_save(self._prefs)
for listener in self._listeners:
self._hass.async_create_task(
async_create_catching_coro(listener(self))
)
async def async_update_google_entity_config(
self, *, entity_id, override_name=_UNDEF, disable_2fa=_UNDEF,
aliases=_UNDEF, should_expose=_UNDEF):
@ -134,6 +153,7 @@ class CloudPreferences:
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs,
PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs,
PREF_ALEXA_REPORT_STATE: self.alexa_report_state,
PREF_CLOUDHOOKS: self.cloudhooks,
PREF_CLOUD_USER: self.cloud_user,
}
@ -156,6 +176,12 @@ class CloudPreferences:
"""Return if Alexa is enabled."""
return self._prefs[PREF_ENABLE_ALEXA]
@property
def alexa_report_state(self):
"""Return if Alexa report state is enabled."""
return self._prefs.get(PREF_ALEXA_REPORT_STATE,
DEFAULT_ALEXA_REPORT_STATE)
@property
def google_enabled(self):
"""Return if Google is enabled."""

View File

@ -9,7 +9,7 @@ bcrypt==3.1.6
certifi>=2018.04.16
cryptography==2.6.1
distro==1.4.0
hass-nabucasa==0.14
hass-nabucasa==0.15
home-assistant-frontend==20190614.0
importlib-metadata==0.15
jinja2>=2.10

View File

@ -562,7 +562,7 @@ habitipy==0.2.0
hangups==0.4.9
# homeassistant.components.cloud
hass-nabucasa==0.14
hass-nabucasa==0.15
# homeassistant.components.mqtt
hbmqtt==0.9.4

View File

@ -145,7 +145,7 @@ ha-ffmpeg==2.0
hangups==0.4.9
# homeassistant.components.cloud
hass-nabucasa==0.14
hass-nabucasa==0.15
# homeassistant.components.mqtt
hbmqtt==0.9.4

View File

@ -38,7 +38,7 @@ class MockConfig(config.AbstractConfig):
pass
DEFAULT_CONFIG = MockConfig()
DEFAULT_CONFIG = MockConfig(None)
def get_new_request(namespace, name, endpoint=None):

View File

@ -1012,7 +1012,7 @@ async def test_exclude_filters(hass):
hass.states.async_set(
'cover.deny', 'off', {'friendly_name': "Blocked cover"})
alexa_config = MockConfig()
alexa_config = MockConfig(hass)
alexa_config.should_expose = entityfilter.generate_filter(
include_domains=[],
include_entities=[],
@ -1045,7 +1045,7 @@ async def test_include_filters(hass):
hass.states.async_set(
'group.allow', 'off', {'friendly_name': "Allowed group"})
alexa_config = MockConfig()
alexa_config = MockConfig(hass)
alexa_config.should_expose = entityfilter.generate_filter(
include_domains=['automation', 'group'],
include_entities=['script.deny'],
@ -1072,7 +1072,7 @@ async def test_never_exposed_entities(hass):
hass.states.async_set(
'group.allow', 'off', {'friendly_name': "Allowed group"})
alexa_config = MockConfig()
alexa_config = MockConfig(hass)
alexa_config.should_expose = entityfilter.generate_filter(
include_domains=['group'],
include_entities=[],
@ -1155,7 +1155,7 @@ async def test_entity_config(hass):
hass.states.async_set(
'light.test_1', 'on', {'friendly_name': "Test light 1"})
alexa_config = MockConfig()
alexa_config = MockConfig(hass)
alexa_config.entity_config = {
'light.test_1': {
'name': 'Config name',

View File

@ -3,6 +3,8 @@ import pytest
from unittest.mock import patch
from homeassistant.components.cloud import prefs
from . import mock_cloud, mock_cloud_prefs
@ -18,3 +20,11 @@ def mock_cloud_fixture(hass):
"""Fixture for cloud component."""
mock_cloud(hass)
return mock_cloud_prefs(hass)
@pytest.fixture
async def cloud_prefs(hass):
"""Fixture for cloud preferences."""
cloud_prefs = prefs.CloudPreferences(hass)
await cloud_prefs.async_initialize()
return cloud_prefs

View File

@ -8,7 +8,7 @@ import pytest
from homeassistant.core import State
from homeassistant.setup import async_setup_component
from homeassistant.components.cloud import (
DOMAIN, ALEXA_SCHEMA, prefs, client)
DOMAIN, ALEXA_SCHEMA, client)
from homeassistant.components.cloud.const import (
PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE)
from tests.components.alexa import test_smart_home as test_alexa
@ -254,18 +254,41 @@ async def test_google_config_should_2fa(
assert not cloud_client.google_config.should_2fa(state)
async def test_alexa_config_expose_entity_prefs(hass):
async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs):
"""Test Alexa config should expose using prefs."""
cloud_prefs = prefs.CloudPreferences(hass)
await cloud_prefs.async_initialize()
entity_conf = {
'should_expose': False
}
await cloud_prefs.async_update(alexa_entity_configs={
'light.kitchen': entity_conf
})
conf = client.AlexaConfig(ALEXA_SCHEMA({}), cloud_prefs)
conf = client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None)
assert not conf.should_expose('light.kitchen')
entity_conf['should_expose'] = True
assert conf.should_expose('light.kitchen')
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)
assert cloud_prefs.alexa_report_state is False
assert conf.should_report_state is False
assert conf.is_reporting_states is False
with patch.object(conf, 'async_get_access_token',
return_value=mock_coro("hello")):
await cloud_prefs.async_update(alexa_report_state=True)
await hass.async_block_till_done()
assert cloud_prefs.alexa_report_state is True
assert conf.should_report_state is True
assert conf.is_reporting_states is True
await cloud_prefs.async_update(alexa_report_state=False)
await hass.async_block_till_done()
assert cloud_prefs.alexa_report_state is False
assert conf.should_report_state is False
assert conf.is_reporting_states is False

View File

@ -363,6 +363,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture,
'google_entity_configs': {},
'google_secure_devices_pin': None,
'alexa_entity_configs': {},
'alexa_report_state': False,
'remote_enabled': False,
},
'alexa_entities': {
@ -371,7 +372,6 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture,
'exclude_domains': [],
'exclude_entities': [],
},
'alexa_domains': ['switch'],
'google_entities': {
'include_domains': ['light'],
'include_entities': [],