Allow exposing domains in cloud (#39216)

pull/39357/head
Paulus Schoutsen 2020-08-28 16:49:17 +02:00 committed by GitHub
parent 414a59ae9f
commit 5217139e0b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 193 additions and 43 deletions

View File

@ -14,18 +14,13 @@ from homeassistant.components.alexa import (
state_report as alexa_state_report,
)
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, HTTP_BAD_REQUEST
from homeassistant.core import callback
from homeassistant.core import callback, split_entity_id
from homeassistant.helpers import entity_registry
from homeassistant.helpers.event import async_call_later
from homeassistant.util.dt import utcnow
from .const import (
CONF_ENTITY_CONFIG,
CONF_FILTER,
DEFAULT_SHOULD_EXPOSE,
PREF_SHOULD_EXPOSE,
RequireRelink,
)
from .const import CONF_ENTITY_CONFIG, CONF_FILTER, PREF_SHOULD_EXPOSE, RequireRelink
from .prefs import CloudPreferences
_LOGGER = logging.getLogger(__name__)
@ -37,7 +32,7 @@ SYNC_DELAY = 1
class AlexaConfig(alexa_config.AbstractConfig):
"""Alexa Configuration."""
def __init__(self, hass, config, prefs, cloud):
def __init__(self, hass, config, prefs: CloudPreferences, cloud):
"""Initialize the Alexa config."""
super().__init__(hass)
self._config = config
@ -46,6 +41,7 @@ class AlexaConfig(alexa_config.AbstractConfig):
self._token = None
self._token_valid = None
self._cur_entity_prefs = prefs.alexa_entity_configs
self._cur_default_expose = prefs.alexa_default_expose
self._alexa_sync_unsub = None
self._endpoint = None
@ -99,7 +95,17 @@ class AlexaConfig(alexa_config.AbstractConfig):
entity_configs = self._prefs.alexa_entity_configs
entity_config = entity_configs.get(entity_id, {})
return entity_config.get(PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
entity_expose = entity_config.get(PREF_SHOULD_EXPOSE)
if entity_expose is not None:
return entity_expose
default_expose = self._prefs.alexa_default_expose
# Backwards compat
if default_expose is None:
return True
return split_entity_id(entity_id)[0] in default_expose
@callback
def async_invalidate_access_token(self):
@ -147,16 +153,24 @@ class AlexaConfig(alexa_config.AbstractConfig):
await self.async_sync_entities()
return
# If entity prefs are the same or we have filter in config.yaml,
# don't sync.
# If user has filter in config.yaml, don't sync.
if not self._config[CONF_FILTER].empty_filter:
return
# If entity prefs are the same, don't sync.
if (
self._cur_entity_prefs is prefs.alexa_entity_configs
or not self._config[CONF_FILTER].empty_filter
and self._cur_default_expose is prefs.alexa_default_expose
):
return
if self._alexa_sync_unsub:
self._alexa_sync_unsub()
self._alexa_sync_unsub = None
if self._cur_default_expose is not prefs.alexa_default_expose:
await self.async_sync_entities()
return
self._alexa_sync_unsub = async_call_later(
self.hass, SYNC_DELAY, self._sync_prefs

View File

@ -18,10 +18,25 @@ PREF_ALIASES = "aliases"
PREF_SHOULD_EXPOSE = "should_expose"
PREF_GOOGLE_LOCAL_WEBHOOK_ID = "google_local_webhook_id"
PREF_USERNAME = "username"
DEFAULT_SHOULD_EXPOSE = True
PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose"
PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose"
DEFAULT_DISABLE_2FA = False
DEFAULT_ALEXA_REPORT_STATE = False
DEFAULT_GOOGLE_REPORT_STATE = False
DEFAULT_EXPOSED_DOMAINS = [
"climate",
"cover",
"fan",
"humidifier",
"light",
"lock",
"scene",
"script",
"sensor",
"switch",
"vacuum",
"water_heater",
]
CONF_ALEXA = "alexa"
CONF_ALIASES = "aliases"

View File

@ -11,16 +11,16 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STARTED,
HTTP_OK,
)
from homeassistant.core import CoreState, callback
from homeassistant.core import CoreState, callback, split_entity_id
from homeassistant.helpers import entity_registry
from .const import (
CONF_ENTITY_CONFIG,
DEFAULT_DISABLE_2FA,
DEFAULT_SHOULD_EXPOSE,
PREF_DISABLE_2FA,
PREF_SHOULD_EXPOSE,
)
from .prefs import CloudPreferences
_LOGGER = logging.getLogger(__name__)
@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__)
class CloudGoogleConfig(AbstractConfig):
"""HA Cloud Configuration for Google Assistant."""
def __init__(self, hass, config, cloud_user, prefs, cloud):
def __init__(self, hass, config, cloud_user, prefs: CloudPreferences, cloud):
"""Initialize the Google config."""
super().__init__(hass)
self._config = config
@ -36,6 +36,7 @@ class CloudGoogleConfig(AbstractConfig):
self._prefs = prefs
self._cloud = cloud
self._cur_entity_prefs = self._prefs.google_entity_configs
self._cur_default_expose = self._prefs.google_default_expose
self._sync_entities_lock = asyncio.Lock()
self._sync_on_started = False
@ -104,7 +105,17 @@ class CloudGoogleConfig(AbstractConfig):
entity_configs = self._prefs.google_entity_configs
entity_config = entity_configs.get(entity_id, {})
return entity_config.get(PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
entity_expose = entity_config.get(PREF_SHOULD_EXPOSE)
if entity_expose is not None:
return entity_expose
default_expose = self._prefs.google_default_expose
# Backwards compat
if default_expose is None:
return True
return split_entity_id(entity_id)[0] in default_expose
@property
def agent_user_id(self):
@ -153,8 +164,8 @@ class CloudGoogleConfig(AbstractConfig):
# don't sync.
elif (
self._cur_entity_prefs is not prefs.google_entity_configs
and self._config["filter"].empty_filter
):
or self._cur_default_expose is not prefs.google_default_expose
) and self._config["filter"].empty_filter:
self.async_schedule_google_sync_all()
if self.enabled and not self.is_local_sdk_active:
@ -162,6 +173,9 @@ class CloudGoogleConfig(AbstractConfig):
elif not self.enabled and self.is_local_sdk_active:
self.async_disable_local_sdk()
self._cur_entity_prefs = prefs.google_entity_configs
self._cur_default_expose = prefs.google_default_expose
async def _handle_entity_registry_updated(self, event):
"""Handle when entity registry updated."""
if not self.enabled or not self._cloud.is_logged_in:

View File

@ -24,9 +24,11 @@ from homeassistant.core import callback
from .const import (
DOMAIN,
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_REPORT_STATE,
PREF_ENABLE_ALEXA,
PREF_ENABLE_GOOGLE,
PREF_GOOGLE_DEFAULT_EXPOSE,
PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_SECURE_DEVICES_PIN,
REQUEST_TIMEOUT,
@ -371,6 +373,8 @@ async def websocket_subscription(hass, connection, msg):
vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_REPORT_STATE): bool,
vol.Optional(PREF_ALEXA_DEFAULT_EXPOSE): [str],
vol.Optional(PREF_GOOGLE_DEFAULT_EXPOSE): [str],
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
}
)
@ -514,7 +518,7 @@ async def google_assistant_list(hass, connection, msg):
{
"type": "cloud/google_assistant/entities/update",
"entity_id": str,
vol.Optional("should_expose"): bool,
vol.Optional("should_expose"): vol.Any(None, bool),
vol.Optional("override_name"): str,
vol.Optional("aliases"): [str],
vol.Optional("disable_2fa"): bool,
@ -566,7 +570,7 @@ async def alexa_list(hass, connection, msg):
{
"type": "cloud/alexa/entities/update",
"entity_id": str,
vol.Optional("should_expose"): bool,
vol.Optional("should_expose"): vol.Any(None, bool),
}
)
async def alexa_update(hass, connection, msg):

View File

@ -1,6 +1,6 @@
"""Preference management for cloud."""
from ipaddress import ip_address
from typing import Optional
from typing import List, Optional
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import User
@ -9,8 +9,10 @@ from homeassistant.util.logging import async_create_catching_coro
from .const import (
DEFAULT_ALEXA_REPORT_STATE,
DEFAULT_EXPOSED_DOMAINS,
DEFAULT_GOOGLE_REPORT_STATE,
DOMAIN,
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_ENTITY_CONFIGS,
PREF_ALEXA_REPORT_STATE,
PREF_ALIASES,
@ -20,6 +22,7 @@ from .const import (
PREF_ENABLE_ALEXA,
PREF_ENABLE_GOOGLE,
PREF_ENABLE_REMOTE,
PREF_GOOGLE_DEFAULT_EXPOSE,
PREF_GOOGLE_ENTITY_CONFIGS,
PREF_GOOGLE_LOCAL_WEBHOOK_ID,
PREF_GOOGLE_REPORT_STATE,
@ -81,6 +84,8 @@ class CloudPreferences:
alexa_entity_configs=_UNDEF,
alexa_report_state=_UNDEF,
google_report_state=_UNDEF,
alexa_default_expose=_UNDEF,
google_default_expose=_UNDEF,
):
"""Update user preferences."""
prefs = {**self._prefs}
@ -96,6 +101,8 @@ class CloudPreferences:
(PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs),
(PREF_ALEXA_REPORT_STATE, alexa_report_state),
(PREF_GOOGLE_REPORT_STATE, google_report_state),
(PREF_ALEXA_DEFAULT_EXPOSE, alexa_default_expose),
(PREF_GOOGLE_DEFAULT_EXPOSE, google_default_expose),
):
if value is not _UNDEF:
prefs[key] = value
@ -185,15 +192,17 @@ class CloudPreferences:
def as_dict(self):
"""Return dictionary version."""
return {
PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose,
PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs,
PREF_ALEXA_REPORT_STATE: self.alexa_report_state,
PREF_CLOUDHOOKS: self.cloudhooks,
PREF_ENABLE_ALEXA: self.alexa_enabled,
PREF_ENABLE_GOOGLE: self.google_enabled,
PREF_ENABLE_REMOTE: self.remote_enabled,
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose,
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_GOOGLE_REPORT_STATE: self.google_report_state,
PREF_CLOUDHOOKS: self.cloudhooks,
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
}
@property
@ -219,6 +228,19 @@ class CloudPreferences:
"""Return if Alexa report state is enabled."""
return self._prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE)
@property
def alexa_default_expose(self) -> Optional[List[str]]:
"""Return array of entity domains that are exposed by default to Alexa.
Can return None, in which case for backwards should be interpreted as allow all domains.
"""
return self._prefs.get(PREF_ALEXA_DEFAULT_EXPOSE)
@property
def alexa_entity_configs(self):
"""Return Alexa Entity configurations."""
return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {})
@property
def google_enabled(self):
"""Return if Google is enabled."""
@ -245,9 +267,12 @@ class CloudPreferences:
return self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID]
@property
def alexa_entity_configs(self):
"""Return Alexa Entity configurations."""
return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {})
def google_default_expose(self) -> Optional[List[str]]:
"""Return array of entity domains that are exposed by default to Google.
Can return None, in which case for backwards should be interpreted as allow all domains.
"""
return self._prefs.get(PREF_GOOGLE_DEFAULT_EXPOSE)
@property
def cloudhooks(self):
@ -322,14 +347,16 @@ class CloudPreferences:
def _empty_config(self, username):
"""Return an empty config."""
return {
PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
PREF_ALEXA_ENTITY_CONFIGS: {},
PREF_CLOUD_USER: None,
PREF_CLOUDHOOKS: {},
PREF_ENABLE_ALEXA: True,
PREF_ENABLE_GOOGLE: True,
PREF_ENABLE_REMOTE: False,
PREF_GOOGLE_SECURE_DEVICES_PIN: None,
PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
PREF_GOOGLE_ENTITY_CONFIGS: {},
PREF_ALEXA_ENTITY_CONFIGS: {},
PREF_CLOUDHOOKS: {},
PREF_CLOUD_USER: None,
PREF_USERNAME: username,
PREF_GOOGLE_LOCAL_WEBHOOK_ID: self._hass.components.webhook.async_generate_id(),
PREF_GOOGLE_SECURE_DEVICES_PIN: None,
PREF_USERNAME: username,
}

View File

@ -12,13 +12,22 @@ from tests.common import async_fire_time_changed
async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs):
"""Test Alexa config should expose using prefs."""
entity_conf = {"should_expose": False}
await cloud_prefs.async_update(alexa_entity_configs={"light.kitchen": entity_conf})
await cloud_prefs.async_update(
alexa_entity_configs={"light.kitchen": entity_conf},
alexa_default_expose=["light"],
)
conf = alexa_config.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")
entity_conf["should_expose"] = None
assert conf.should_expose("light.kitchen")
await cloud_prefs.async_update(alexa_default_expose=["sensor"],)
assert not conf.should_expose("light.kitchen")
async def test_alexa_config_report_state(hass, cloud_prefs):
"""Test Alexa config should expose using prefs."""

View File

@ -1,9 +1,11 @@
"""Test the Cloud Google Config."""
import pytest
from homeassistant.components.cloud import GACTIONS_SCHEMA
from homeassistant.components.cloud.google_config import CloudGoogleConfig
from homeassistant.components.google_assistant import helpers as ga_helpers
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, HTTP_NOT_FOUND
from homeassistant.core import CoreState
from homeassistant.core import CoreState, State
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from homeassistant.util.dt import utcnow
@ -11,19 +13,24 @@ from tests.async_mock import AsyncMock, Mock, patch
from tests.common import async_fire_time_changed
async def test_google_update_report_state(hass, cloud_prefs):
"""Test Google config responds to updating preference."""
config = CloudGoogleConfig(
@pytest.fixture
def mock_conf(hass, cloud_prefs):
"""Mock Google conf."""
return CloudGoogleConfig(
hass,
GACTIONS_SCHEMA({}),
"mock-user-id",
cloud_prefs,
Mock(claims={"cognito:username": "abcdefghjkl"}),
)
await config.async_initialize()
await config.async_connect_agent_user("mock-user-id")
with patch.object(config, "async_sync_entities") as mock_sync, patch(
async def test_google_update_report_state(mock_conf, hass, cloud_prefs):
"""Test Google config responds to updating preference."""
await mock_conf.async_initialize()
await mock_conf.async_connect_agent_user("mock-user-id")
with patch.object(mock_conf, "async_sync_entities") as mock_sync, patch(
"homeassistant.components.google_assistant.report_state.async_enable_report_state"
) as mock_report_state:
await cloud_prefs.async_update(google_report_state=True)
@ -161,3 +168,24 @@ async def test_google_entity_registry_sync(hass, mock_cloud_login, cloud_prefs):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert len(mock_sync.mock_calls) == 1
async def test_google_config_expose_entity_prefs(mock_conf, cloud_prefs):
"""Test Google config should expose using prefs."""
entity_conf = {"should_expose": False}
await cloud_prefs.async_update(
google_entity_configs={"light.kitchen": entity_conf},
google_default_expose=["light"],
)
state = State("light.kitchen", "on")
assert not mock_conf.should_expose(state)
entity_conf["should_expose"] = True
assert mock_conf.should_expose(state)
entity_conf["should_expose"] = None
assert mock_conf.should_expose(state)
await cloud_prefs.async_update(google_default_expose=["sensor"],)
assert not mock_conf.should_expose(state)

View File

@ -355,6 +355,8 @@ async def test_websocket_status(
"google_enabled": True,
"google_entity_configs": {},
"google_secure_devices_pin": None,
"google_default_expose": None,
"alexa_default_expose": None,
"alexa_entity_configs": {},
"alexa_report_state": False,
"google_report_state": False,
@ -487,6 +489,8 @@ async def test_websocket_update_preferences(
"alexa_enabled": False,
"google_enabled": False,
"google_secure_devices_pin": "1234",
"google_default_expose": ["light", "switch"],
"alexa_default_expose": ["sensor", "media_player"],
}
)
response = await client.receive_json()
@ -495,6 +499,8 @@ async def test_websocket_update_preferences(
assert not setup_api.google_enabled
assert not setup_api.alexa_enabled
assert setup_api.google_secure_devices_pin == "1234"
assert setup_api.google_default_expose == ["light", "switch"]
assert setup_api.alexa_default_expose == ["sensor", "media_player"]
async def test_websocket_update_preferences_require_relink(
@ -746,6 +752,25 @@ async def test_update_google_entity(hass, hass_ws_client, setup_api, mock_cloud_
"disable_2fa": False,
}
await client.send_json(
{
"id": 6,
"type": "cloud/google_assistant/entities/update",
"entity_id": "light.kitchen",
"should_expose": None,
}
)
response = await client.receive_json()
assert response["success"]
prefs = hass.data[DOMAIN].client.prefs
assert prefs.google_entity_configs["light.kitchen"] == {
"should_expose": None,
"override_name": "updated name",
"aliases": ["lefty", "righty"],
"disable_2fa": False,
}
async def test_enabling_remote_trusted_proxies_local4(
hass, hass_ws_client, setup_api, mock_cloud_login
@ -834,6 +859,20 @@ async def test_update_alexa_entity(hass, hass_ws_client, setup_api, mock_cloud_l
prefs = hass.data[DOMAIN].client.prefs
assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": False}
await client.send_json(
{
"id": 6,
"type": "cloud/alexa/entities/update",
"entity_id": "light.kitchen",
"should_expose": None,
}
)
response = await client.receive_json()
assert response["success"]
prefs = hass.data[DOMAIN].client.prefs
assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": None}
async def test_sync_alexa_entities_timeout(
hass, hass_ws_client, setup_api, mock_cloud_login