Add Google Report State (#27112)

* Add Google Report State

* UPDATE codeowners"

* Add config option for dev mode

* update library

* lint

* Bug fixes
This commit is contained in:
Paulus Schoutsen 2019-10-03 04:02:38 -07:00 committed by Pascal Vizeli
parent 3e99743244
commit f184bf4d85
26 changed files with 510 additions and 56 deletions

View file

@ -16,6 +16,7 @@ homeassistant/scripts/check_config.py @kellerza
homeassistant/components/adguard/* @frenck homeassistant/components/adguard/* @frenck
homeassistant/components/airvisual/* @bachya homeassistant/components/airvisual/* @bachya
homeassistant/components/alarm_control_panel/* @colinodell homeassistant/components/alarm_control_panel/* @colinodell
homeassistant/components/alexa/* @home-assistant/cloud
homeassistant/components/alpha_vantage/* @fabaff homeassistant/components/alpha_vantage/* @fabaff
homeassistant/components/amazon_polly/* @robbiet480 homeassistant/components/amazon_polly/* @robbiet480
homeassistant/components/ambiclimate/* @danielhiversen homeassistant/components/ambiclimate/* @danielhiversen
@ -106,6 +107,7 @@ homeassistant/components/geonetnz_quakes/* @exxamalte
homeassistant/components/gitter/* @fabaff homeassistant/components/gitter/* @fabaff
homeassistant/components/glances/* @fabaff homeassistant/components/glances/* @fabaff
homeassistant/components/gntp/* @robbiet480 homeassistant/components/gntp/* @robbiet480
homeassistant/components/google_assistant/* @home-assistant/cloud
homeassistant/components/google_cloud/* @lufton homeassistant/components/google_cloud/* @lufton
homeassistant/components/google_translate/* @awarecan homeassistant/components/google_translate/* @awarecan
homeassistant/components/google_travel_time/* @robbiet480 homeassistant/components/google_travel_time/* @robbiet480

View file

@ -3,8 +3,6 @@
"name": "Alexa", "name": "Alexa",
"documentation": "https://www.home-assistant.io/integrations/alexa", "documentation": "https://www.home-assistant.io/integrations/alexa",
"requirements": [], "requirements": [],
"dependencies": [ "dependencies": ["http"],
"http" "codeowners": ["@home-assistant/cloud"]
],
"codeowners": []
} }

View file

@ -34,6 +34,7 @@ from .const import (
CONF_REMOTE_API_URL, CONF_REMOTE_API_URL,
CONF_SUBSCRIPTION_INFO_URL, CONF_SUBSCRIPTION_INFO_URL,
CONF_USER_POOL_ID, CONF_USER_POOL_ID,
CONF_GOOGLE_ACTIONS_REPORT_STATE_URL,
DOMAIN, DOMAIN,
MODE_DEV, MODE_DEV,
MODE_PROD, MODE_PROD,
@ -96,7 +97,8 @@ 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, vol.Optional(CONF_ALEXA_ACCESS_TOKEN_URL): vol.Url(),
vol.Optional(CONF_GOOGLE_ACTIONS_REPORT_STATE_URL): vol.Url(),
} }
) )
}, },

View file

@ -98,7 +98,7 @@ class CloudClient(Interface):
if not self._google_config: if not self._google_config:
assert self.cloud is not None assert self.cloud is not None
self._google_config = google_config.CloudGoogleConfig( self._google_config = google_config.CloudGoogleConfig(
self.google_user_config, self._prefs, self.cloud self._hass, self.google_user_config, self._prefs, self.cloud
) )
return self._google_config return self._google_config
@ -107,13 +107,17 @@ class CloudClient(Interface):
"""Initialize the client.""" """Initialize the client."""
self.cloud = cloud self.cloud = cloud
if not self.alexa_config.should_report_state or not self.cloud.is_logged_in: if not self.cloud.is_logged_in:
return return
try: if self.alexa_config.should_report_state:
await self.alexa_config.async_enable_proactive_mode() try:
except alexa_errors.NoTokenAvailable: await self.alexa_config.async_enable_proactive_mode()
pass except alexa_errors.NoTokenAvailable:
pass
if self.google_config.should_report_state:
self.google_config.async_enable_report_state()
async def cleanups(self) -> None: async def cleanups(self) -> None:
"""Cleanup some stuff after logout.""" """Cleanup some stuff after logout."""

View file

@ -9,6 +9,7 @@ PREF_GOOGLE_SECURE_DEVICES_PIN = "google_secure_devices_pin"
PREF_CLOUDHOOKS = "cloudhooks" PREF_CLOUDHOOKS = "cloudhooks"
PREF_CLOUD_USER = "cloud_user" PREF_CLOUD_USER = "cloud_user"
PREF_GOOGLE_ENTITY_CONFIGS = "google_entity_configs" PREF_GOOGLE_ENTITY_CONFIGS = "google_entity_configs"
PREF_GOOGLE_REPORT_STATE = "google_report_state"
PREF_ALEXA_ENTITY_CONFIGS = "alexa_entity_configs" PREF_ALEXA_ENTITY_CONFIGS = "alexa_entity_configs"
PREF_ALEXA_REPORT_STATE = "alexa_report_state" PREF_ALEXA_REPORT_STATE = "alexa_report_state"
PREF_OVERRIDE_NAME = "override_name" PREF_OVERRIDE_NAME = "override_name"
@ -18,6 +19,7 @@ PREF_SHOULD_EXPOSE = "should_expose"
DEFAULT_SHOULD_EXPOSE = True DEFAULT_SHOULD_EXPOSE = True
DEFAULT_DISABLE_2FA = False DEFAULT_DISABLE_2FA = False
DEFAULT_ALEXA_REPORT_STATE = False DEFAULT_ALEXA_REPORT_STATE = False
DEFAULT_GOOGLE_REPORT_STATE = False
CONF_ALEXA = "alexa" CONF_ALEXA = "alexa"
CONF_ALIASES = "aliases" CONF_ALIASES = "aliases"
@ -33,6 +35,7 @@ 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" CONF_ALEXA_ACCESS_TOKEN_URL = "alexa_access_token_url"
CONF_GOOGLE_ACTIONS_REPORT_STATE_URL = "google_actions_report_state_url"
MODE_DEV = "development" MODE_DEV = "development"
MODE_PROD = "production" MODE_PROD = "production"

View file

@ -1,6 +1,13 @@
"""Google config for Cloud.""" """Google config for Cloud."""
import asyncio
import logging
import async_timeout
from hass_nabucasa.google_report_state import ErrorResponse
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.components.google_assistant.helpers import AbstractConfig from homeassistant.components.google_assistant.helpers import AbstractConfig
from homeassistant.helpers import entity_registry
from .const import ( from .const import (
PREF_SHOULD_EXPOSE, PREF_SHOULD_EXPOSE,
@ -10,15 +17,31 @@ from .const import (
DEFAULT_DISABLE_2FA, DEFAULT_DISABLE_2FA,
) )
_LOGGER = logging.getLogger(__name__)
class CloudGoogleConfig(AbstractConfig): class CloudGoogleConfig(AbstractConfig):
"""HA Cloud Configuration for Google Assistant.""" """HA Cloud Configuration for Google Assistant."""
def __init__(self, config, prefs, cloud): def __init__(self, hass, config, prefs, cloud):
"""Initialize the Alexa config.""" """Initialize the Google config."""
super().__init__(hass)
self._config = config self._config = config
self._prefs = prefs self._prefs = prefs
self._cloud = cloud self._cloud = cloud
self._cur_entity_prefs = self._prefs.google_entity_configs
self._sync_entities_lock = asyncio.Lock()
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 Google is enabled."""
return self._prefs.google_enabled
@property @property
def agent_user_id(self): def agent_user_id(self):
@ -35,16 +58,25 @@ class CloudGoogleConfig(AbstractConfig):
"""Return entity config.""" """Return entity config."""
return self._prefs.google_secure_devices_pin return self._prefs.google_secure_devices_pin
@property
def should_report_state(self):
"""Return if states should be proactively reported."""
return self._prefs.google_report_state
def should_expose(self, state): def should_expose(self, state):
"""If an entity should be exposed.""" """If a state object should be exposed."""
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return self._should_expose_entity_id(state.entity_id)
def _should_expose_entity_id(self, entity_id):
"""If an entity ID should be exposed."""
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False return False
if not self._config["filter"].empty_filter: if not self._config["filter"].empty_filter:
return self._config["filter"](state.entity_id) return self._config["filter"](entity_id)
entity_configs = self._prefs.google_entity_configs entity_configs = self._prefs.google_entity_configs
entity_config = entity_configs.get(state.entity_id, {}) entity_config = entity_configs.get(entity_id, {})
return entity_config.get(PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) return entity_config.get(PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
def should_2fa(self, state): def should_2fa(self, state):
@ -52,3 +84,72 @@ class CloudGoogleConfig(AbstractConfig):
entity_configs = self._prefs.google_entity_configs entity_configs = self._prefs.google_entity_configs
entity_config = entity_configs.get(state.entity_id, {}) entity_config = entity_configs.get(state.entity_id, {})
return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
async def async_report_state(self, message):
"""Send a state report to Google."""
try:
await self._cloud.google_report_state.async_send_message(message)
except ErrorResponse as err:
_LOGGER.warning("Error reporting state - %s: %s", err.code, err.message)
async def _async_request_sync_devices(self):
"""Trigger a sync with Google."""
if self._sync_entities_lock.locked():
return 200
websession = self.hass.helpers.aiohttp_client.async_get_clientsession()
async with self._sync_entities_lock:
with async_timeout.timeout(10):
await self._cloud.auth.async_check_token()
_LOGGER.debug("Requesting sync")
with async_timeout.timeout(30):
req = await websession.post(
self._cloud.google_actions_sync_url,
headers={"authorization": self._cloud.id_token},
)
_LOGGER.debug("Finished requesting syncing: %s", req.status)
return req.status
async def async_deactivate_report_state(self):
"""Turn off report state and disable further state reporting.
Called when the user disconnects their account from Google.
"""
await self._prefs.async_update(google_report_state=False)
async def _async_prefs_updated(self, prefs):
"""Handle updated preferences."""
if self.should_report_state != self.is_reporting_state:
if self.should_report_state:
self.async_enable_report_state()
else:
self.async_disable_report_state()
# State reporting is reported as a property on entities.
# So when we change it, we need to sync all entities.
await self.async_sync_entities()
return
# If entity prefs are the same or we have filter in config.yaml,
# don't sync.
if (
self._cur_entity_prefs is prefs.google_entity_configs
or not self._config["filter"].empty_filter
):
return
self.async_schedule_google_sync()
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
entity_id = event.data["entity_id"]
# Schedule a sync if a change was made to an entity that Google knows about
if self._should_expose_entity_id(entity_id):
await self.async_sync_entities()

View file

@ -7,6 +7,7 @@ import attr
import aiohttp import aiohttp
import async_timeout import async_timeout
import voluptuous as vol import voluptuous as vol
from hass_nabucasa import Cloud
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
@ -28,6 +29,7 @@ from .const import (
InvalidTrustedNetworks, InvalidTrustedNetworks,
InvalidTrustedProxies, InvalidTrustedProxies,
PREF_ALEXA_REPORT_STATE, PREF_ALEXA_REPORT_STATE,
PREF_GOOGLE_REPORT_STATE,
RequireRelink, RequireRelink,
) )
@ -171,18 +173,9 @@ class GoogleActionsSyncView(HomeAssistantView):
async def post(self, request): async def post(self, request):
"""Trigger a Google Actions sync.""" """Trigger a Google Actions sync."""
hass = request.app["hass"] hass = request.app["hass"]
cloud = hass.data[DOMAIN] cloud: Cloud = hass.data[DOMAIN]
websession = hass.helpers.aiohttp_client.async_get_clientsession() status = await cloud.client.google_config.async_sync_entities()
return self.json({}, status_code=status)
with async_timeout.timeout(REQUEST_TIMEOUT):
await hass.async_add_job(cloud.auth.check_token)
with async_timeout.timeout(REQUEST_TIMEOUT):
req = await websession.post(
cloud.google_actions_sync_url, headers={"authorization": cloud.id_token}
)
return self.json({}, status_code=req.status)
class CloudLoginView(HomeAssistantView): class CloudLoginView(HomeAssistantView):
@ -366,6 +359,7 @@ async def websocket_subscription(hass, connection, msg):
vol.Optional(PREF_ENABLE_GOOGLE): bool, vol.Optional(PREF_ENABLE_GOOGLE): bool,
vol.Optional(PREF_ENABLE_ALEXA): bool, vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ALEXA_REPORT_STATE): bool, vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
} }
) )

View file

@ -2,7 +2,7 @@
"domain": "cloud", "domain": "cloud",
"name": "Cloud", "name": "Cloud",
"documentation": "https://www.home-assistant.io/integrations/cloud", "documentation": "https://www.home-assistant.io/integrations/cloud",
"requirements": ["hass-nabucasa==0.17"], "requirements": ["hass-nabucasa==0.22"],
"dependencies": ["http", "webhook"], "dependencies": ["http", "webhook"],
"codeowners": ["@home-assistant/cloud"] "codeowners": ["@home-assistant/cloud"]
} }

View file

@ -20,6 +20,8 @@ from .const import (
PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_ENTITY_CONFIGS,
PREF_ALEXA_REPORT_STATE, PREF_ALEXA_REPORT_STATE,
DEFAULT_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE,
PREF_GOOGLE_REPORT_STATE,
DEFAULT_GOOGLE_REPORT_STATE,
InvalidTrustedNetworks, InvalidTrustedNetworks,
InvalidTrustedProxies, InvalidTrustedProxies,
) )
@ -74,6 +76,7 @@ class CloudPreferences:
google_entity_configs=_UNDEF, google_entity_configs=_UNDEF,
alexa_entity_configs=_UNDEF, alexa_entity_configs=_UNDEF,
alexa_report_state=_UNDEF, alexa_report_state=_UNDEF,
google_report_state=_UNDEF,
): ):
"""Update user preferences.""" """Update user preferences."""
for key, value in ( for key, value in (
@ -86,6 +89,7 @@ class CloudPreferences:
(PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs), (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs),
(PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs), (PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs),
(PREF_ALEXA_REPORT_STATE, alexa_report_state), (PREF_ALEXA_REPORT_STATE, alexa_report_state),
(PREF_GOOGLE_REPORT_STATE, google_report_state),
): ):
if value is not _UNDEF: if value is not _UNDEF:
self._prefs[key] = value self._prefs[key] = value
@ -164,6 +168,7 @@ class CloudPreferences:
PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs,
PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs,
PREF_ALEXA_REPORT_STATE: self.alexa_report_state, PREF_ALEXA_REPORT_STATE: self.alexa_report_state,
PREF_GOOGLE_REPORT_STATE: self.google_report_state,
PREF_CLOUDHOOKS: self.cloudhooks, PREF_CLOUDHOOKS: self.cloudhooks,
PREF_CLOUD_USER: self.cloud_user, PREF_CLOUD_USER: self.cloud_user,
} }
@ -196,6 +201,11 @@ class CloudPreferences:
"""Return if Google is enabled.""" """Return if Google is enabled."""
return self._prefs[PREF_ENABLE_GOOGLE] return self._prefs[PREF_ENABLE_GOOGLE]
@property
def google_report_state(self):
"""Return if Google report state is enabled."""
return self._prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE)
@property @property
def google_secure_devices_pin(self): def google_secure_devices_pin(self):
"""Return if Google is allowed to unlock locks.""" """Return if Google is allowed to unlock locks."""

View file

@ -83,6 +83,13 @@ async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]):
try: try:
with async_timeout.timeout(15): with async_timeout.timeout(15):
agent_user_id = call.data.get("agent_user_id") or call.context.user_id agent_user_id = call.data.get("agent_user_id") or call.context.user_id
if agent_user_id is None:
_LOGGER.warning(
"No agent_user_id supplied for request_sync. Call as a user or pass in user id as agent_user_id."
)
return
res = await websession.post( res = await websession.post(
REQUEST_SYNC_BASE_URL, REQUEST_SYNC_BASE_URL,
params={"key": api_key}, params={"key": api_key},

View file

@ -3,7 +3,8 @@ from asyncio import gather
from collections.abc import Mapping from collections.abc import Mapping
from typing import List from typing import List
from homeassistant.core import Context, callback from homeassistant.core import Context, callback, HomeAssistant, State
from homeassistant.helpers.event import async_call_later
from homeassistant.const import ( from homeassistant.const import (
CONF_NAME, CONF_NAME,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
@ -22,10 +23,24 @@ from .const import (
) )
from .error import SmartHomeError from .error import SmartHomeError
SYNC_DELAY = 15
class AbstractConfig: class AbstractConfig:
"""Hold the configuration for Google Assistant.""" """Hold the configuration for Google Assistant."""
_unsub_report_state = None
def __init__(self, hass):
"""Initialize abstract config."""
self.hass = hass
self._google_sync_unsub = None
@property
def enabled(self):
"""Return if Google is enabled."""
return False
@property @property
def agent_user_id(self): def agent_user_id(self):
"""Return Agent User Id to use for query responses.""" """Return Agent User Id to use for query responses."""
@ -41,6 +56,17 @@ class AbstractConfig:
"""Return entity config.""" """Return entity config."""
return None return None
@property
def is_reporting_state(self):
"""Return if we're actively reporting states."""
return self._unsub_report_state is not None
@property
def should_report_state(self):
"""Return if states should be proactively reported."""
# pylint: disable=no-self-use
return False
def should_expose(self, state) -> bool: def should_expose(self, state) -> bool:
"""Return if entity should be exposed.""" """Return if entity should be exposed."""
raise NotImplementedError raise NotImplementedError
@ -50,11 +76,66 @@ class AbstractConfig:
# pylint: disable=no-self-use # pylint: disable=no-self-use
return True return True
async def async_report_state(self, message):
"""Send a state report to Google."""
raise NotImplementedError
def async_enable_report_state(self):
"""Enable proactive mode."""
# Circular dep
from .report_state import async_enable_report_state
if self._unsub_report_state is None:
self._unsub_report_state = async_enable_report_state(self.hass, self)
def async_disable_report_state(self):
"""Disable report state."""
if self._unsub_report_state is not None:
self._unsub_report_state()
self._unsub_report_state = None
async def async_sync_entities(self):
"""Sync all entities to Google."""
# Remove any pending sync
if self._google_sync_unsub:
self._google_sync_unsub()
self._google_sync_unsub = None
return await self._async_request_sync_devices()
async def _schedule_callback(self, _now):
"""Handle a scheduled sync callback."""
self._google_sync_unsub = None
await self.async_sync_entities()
@callback
def async_schedule_google_sync(self):
"""Schedule a sync."""
if self._google_sync_unsub:
self._google_sync_unsub()
self._google_sync_unsub = async_call_later(
self.hass, SYNC_DELAY, self._schedule_callback
)
async def _async_request_sync_devices(self) -> int:
"""Trigger a sync with Google.
Return value is the HTTP status code of the sync request.
"""
raise NotImplementedError
async def async_deactivate_report_state(self):
"""Turn off report state and disable further state reporting.
Called when the user disconnects their account from Google.
"""
class RequestData: class RequestData:
"""Hold data associated with a particular request.""" """Hold data associated with a particular request."""
def __init__(self, config, user_id, request_id): def __init__(self, config: AbstractConfig, user_id: str, request_id: str):
"""Initialize the request data.""" """Initialize the request data."""
self.config = config self.config = config
self.request_id = request_id self.request_id = request_id
@ -71,7 +152,7 @@ def get_google_type(domain, device_class):
class GoogleEntity: class GoogleEntity:
"""Adaptation of Entity expressed in Google's terms.""" """Adaptation of Entity expressed in Google's terms."""
def __init__(self, hass, config, state): def __init__(self, hass: HomeAssistant, config: AbstractConfig, state: State):
"""Initialize a Google entity.""" """Initialize a Google entity."""
self.hass = hass self.hass = hass
self.config = config self.config = config
@ -139,7 +220,7 @@ class GoogleEntity:
"name": {"name": name}, "name": {"name": name},
"attributes": {}, "attributes": {},
"traits": [trait.name for trait in traits], "traits": [trait.name for trait in traits],
"willReportState": False, "willReportState": self.config.should_report_state,
"type": device_type, "type": device_type,
} }

View file

@ -25,10 +25,16 @@ _LOGGER = logging.getLogger(__name__)
class GoogleConfig(AbstractConfig): class GoogleConfig(AbstractConfig):
"""Config for manual setup of Google.""" """Config for manual setup of Google."""
def __init__(self, config): def __init__(self, hass, config):
"""Initialize the config.""" """Initialize the config."""
super().__init__(hass)
self._config = config self._config = config
@property
def enabled(self):
"""Return if Google is enabled."""
return True
@property @property
def agent_user_id(self): def agent_user_id(self):
"""Return Agent User Id to use for query responses.""" """Return Agent User Id to use for query responses."""
@ -77,7 +83,7 @@ class GoogleConfig(AbstractConfig):
@callback @callback
def async_register_http(hass, cfg): def async_register_http(hass, cfg):
"""Register HTTP views for Google Assistant.""" """Register HTTP views for Google Assistant."""
hass.http.register_view(GoogleAssistantView(GoogleConfig(cfg))) hass.http.register_view(GoogleAssistantView(GoogleConfig(hass, cfg)))
class GoogleAssistantView(HomeAssistantView): class GoogleAssistantView(HomeAssistantView):

View file

@ -3,8 +3,6 @@
"name": "Google assistant", "name": "Google assistant",
"documentation": "https://www.home-assistant.io/integrations/google_assistant", "documentation": "https://www.home-assistant.io/integrations/google_assistant",
"requirements": [], "requirements": [],
"dependencies": [ "dependencies": ["http"],
"http" "codeowners": ["@home-assistant/cloud"]
],
"codeowners": []
} }

View file

@ -0,0 +1,39 @@
"""Google Report State implementation."""
from homeassistant.core import HomeAssistant, callback
from homeassistant.const import MATCH_ALL
from .helpers import AbstractConfig, GoogleEntity
@callback
def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig):
"""Enable state reporting."""
async def async_entity_state_listener(changed_entity, old_state, new_state):
if not new_state:
return
if not google_config.should_expose(new_state):
return
entity = GoogleEntity(hass, google_config, new_state)
if not entity.is_supported():
return
entity_data = entity.query_serialize()
if old_state:
old_entity = GoogleEntity(hass, google_config, old_state)
# Only report to Google if data that Google cares about has changed
if entity_data == old_entity.query_serialize():
return
await google_config.async_report_state(
{"devices": {"states": {changed_entity: entity_data}}}
)
return hass.helpers.event.async_track_state_change(
MATCH_ALL, async_entity_state_listener
)

View file

@ -193,11 +193,12 @@ async def handle_devices_execute(hass, data, payload):
@HANDLERS.register("action.devices.DISCONNECT") @HANDLERS.register("action.devices.DISCONNECT")
async def async_devices_disconnect(hass, data, payload): async def async_devices_disconnect(hass, data: RequestData, payload):
"""Handle action.devices.DISCONNECT request. """Handle action.devices.DISCONNECT request.
https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect
""" """
await data.config.async_deactivate_report_state()
return None return None

View file

@ -10,7 +10,7 @@ certifi>=2019.6.16
contextvars==2.4;python_version<"3.7" contextvars==2.4;python_version<"3.7"
cryptography==2.7 cryptography==2.7
distro==1.4.0 distro==1.4.0
hass-nabucasa==0.17 hass-nabucasa==0.22
home-assistant-frontend==20191002.0 home-assistant-frontend==20191002.0
importlib-metadata==0.23 importlib-metadata==0.23
jinja2>=2.10.1 jinja2>=2.10.1

View file

@ -607,7 +607,7 @@ habitipy==0.2.0
hangups==0.4.9 hangups==0.4.9
# homeassistant.components.cloud # homeassistant.components.cloud
hass-nabucasa==0.17 hass-nabucasa==0.22
# homeassistant.components.mqtt # homeassistant.components.mqtt
hbmqtt==0.9.5 hbmqtt==0.9.5

View file

@ -164,7 +164,7 @@ ha-ffmpeg==2.0
hangups==0.4.9 hangups==0.4.9
# homeassistant.components.cloud # homeassistant.components.cloud
hass-nabucasa==0.17 hass-nabucasa==0.22
# homeassistant.components.mqtt # homeassistant.components.mqtt
hbmqtt==0.9.5 hbmqtt==0.9.5

View file

@ -59,7 +59,7 @@ async def test_alexa_config_invalidate_token(hass, cloud_prefs, aioclient_mock):
cloud_prefs, cloud_prefs,
Mock( Mock(
alexa_access_token_url="http://example/alexa_token", alexa_access_token_url="http://example/alexa_token",
run_executor=Mock(side_effect=mock_coro), auth=Mock(async_check_token=Mock(side_effect=mock_coro)),
websession=hass.helpers.aiohttp_client.async_get_clientsession(), websession=hass.helpers.aiohttp_client.async_get_clientsession(),
), ),
) )
@ -160,7 +160,11 @@ async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs):
with patch_sync_helper() as (to_update, to_remove): with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire( hass.bus.async_fire(
EVENT_ENTITY_REGISTRY_UPDATED, EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "update", "entity_id": "light.kitchen"}, {
"action": "update",
"entity_id": "light.kitchen",
"changes": ["entity_id"],
},
) )
await hass.async_block_till_done() await hass.async_block_till_done()

View file

@ -0,0 +1,121 @@
"""Test the Cloud Google Config."""
from unittest.mock import patch, Mock
from homeassistant.components.google_assistant import helpers as ga_helpers
from homeassistant.components.cloud import GACTIONS_SCHEMA
from homeassistant.components.cloud.google_config import CloudGoogleConfig
from homeassistant.util.dt import utcnow
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from tests.common import mock_coro, async_fire_time_changed
async def test_google_update_report_state(hass, cloud_prefs):
"""Test Google config responds to updating preference."""
config = CloudGoogleConfig(hass, GACTIONS_SCHEMA({}), cloud_prefs, None)
with patch.object(
config, "async_sync_entities", side_effect=mock_coro
) 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)
await hass.async_block_till_done()
assert len(mock_sync.mock_calls) == 1
assert len(mock_report_state.mock_calls) == 1
async def test_sync_entities(aioclient_mock, hass, cloud_prefs):
"""Test sync devices."""
aioclient_mock.post("http://example.com", status=404)
config = CloudGoogleConfig(
hass,
GACTIONS_SCHEMA({}),
cloud_prefs,
Mock(
google_actions_sync_url="http://example.com",
auth=Mock(async_check_token=Mock(side_effect=mock_coro)),
),
)
assert await config.async_sync_entities() == 404
async def test_google_update_expose_trigger_sync(hass, cloud_prefs):
"""Test Google config responds to updating exposed entities."""
config = CloudGoogleConfig(hass, GACTIONS_SCHEMA({}), cloud_prefs, None)
with patch.object(
config, "async_sync_entities", side_effect=mock_coro
) as mock_sync, patch.object(ga_helpers, "SYNC_DELAY", 0):
await cloud_prefs.async_update_google_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 len(mock_sync.mock_calls) == 1
with patch.object(
config, "async_sync_entities", side_effect=mock_coro
) as mock_sync, patch.object(ga_helpers, "SYNC_DELAY", 0):
await cloud_prefs.async_update_google_entity_config(
entity_id="light.kitchen", should_expose=False
)
await cloud_prefs.async_update_google_entity_config(
entity_id="binary_sensor.door", should_expose=True
)
await cloud_prefs.async_update_google_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 len(mock_sync.mock_calls) == 1
async def test_google_entity_registry_sync(hass, mock_cloud_login, cloud_prefs):
"""Test Google config responds to entity registry."""
config = CloudGoogleConfig(
hass, GACTIONS_SCHEMA({}), cloud_prefs, hass.data["cloud"]
)
with patch.object(
config, "async_sync_entities", side_effect=mock_coro
) as mock_sync, patch.object(ga_helpers, "SYNC_DELAY", 0):
hass.bus.async_fire(
EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "create", "entity_id": "light.kitchen"},
)
await hass.async_block_till_done()
assert len(mock_sync.mock_calls) == 1
with patch.object(
config, "async_sync_entities", side_effect=mock_coro
) as mock_sync, patch.object(ga_helpers, "SYNC_DELAY", 0):
hass.bus.async_fire(
EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "remove", "entity_id": "light.kitchen"},
)
await hass.async_block_till_done()
assert len(mock_sync.mock_calls) == 1
with patch.object(
config, "async_sync_entities", side_effect=mock_coro
) as mock_sync, patch.object(ga_helpers, "SYNC_DELAY", 0):
hass.bus.async_fire(
EVENT_ENTITY_REGISTRY_UPDATED,
{
"action": "update",
"entity_id": "light.kitchen",
"changes": ["entity_id"],
},
)
await hass.async_block_till_done()
assert len(mock_sync.mock_calls) == 1

View file

@ -33,7 +33,9 @@ SUBSCRIPTION_INFO_URL = "https://api-test.hass.io/subscription_info"
@pytest.fixture() @pytest.fixture()
def mock_auth(): def mock_auth():
"""Mock check token.""" """Mock check token."""
with patch("hass_nabucasa.auth.CognitoAuth.check_token"): with patch(
"hass_nabucasa.auth.CognitoAuth.async_check_token", side_effect=mock_coro
):
yield yield
@ -357,6 +359,7 @@ async def test_websocket_status(
"google_secure_devices_pin": None, "google_secure_devices_pin": None,
"alexa_entity_configs": {}, "alexa_entity_configs": {},
"alexa_report_state": False, "alexa_report_state": False,
"google_report_state": False,
"remote_enabled": False, "remote_enabled": False,
}, },
"alexa_entities": { "alexa_entities": {

View file

@ -28,6 +28,13 @@ async def test_constructor_loads_info_from_config(hass):
"user_pool_id": "test-user_pool_id", "user_pool_id": "test-user_pool_id",
"region": "test-region", "region": "test-region",
"relayer": "test-relayer", "relayer": "test-relayer",
"google_actions_sync_url": "http://test-google_actions_sync_url",
"subscription_info_url": "http://test-subscription-info-url",
"cloudhook_create_url": "http://test-cloudhook_create_url",
"remote_api_url": "http://test-remote_api_url",
"alexa_access_token_url": "http://test-alexa-token-url",
"acme_directory_server": "http://test-acme-directory-server",
"google_actions_report_state_url": "http://test-google-actions-report-state-url",
}, },
}, },
) )
@ -39,6 +46,16 @@ async def test_constructor_loads_info_from_config(hass):
assert cl.user_pool_id == "test-user_pool_id" assert cl.user_pool_id == "test-user_pool_id"
assert cl.region == "test-region" assert cl.region == "test-region"
assert cl.relayer == "test-relayer" assert cl.relayer == "test-relayer"
assert cl.google_actions_sync_url == "http://test-google_actions_sync_url"
assert cl.subscription_info_url == "http://test-subscription-info-url"
assert cl.cloudhook_create_url == "http://test-cloudhook_create_url"
assert cl.remote_api_url == "http://test-remote_api_url"
assert cl.alexa_access_token_url == "http://test-alexa-token-url"
assert cl.acme_directory_server == "http://test-acme-directory-server"
assert (
cl.google_actions_report_state_url
== "http://test-google-actions-report-state-url"
)
async def test_remote_services(hass, mock_cloud_fixture, hass_read_only_user): async def test_remote_services(hass, mock_cloud_fixture, hass_read_only_user):

View file

@ -6,9 +6,15 @@ class MockConfig(helpers.AbstractConfig):
"""Fake config that always exposes everything.""" """Fake config that always exposes everything."""
def __init__( def __init__(
self, *, secure_devices_pin=None, should_expose=None, entity_config=None self,
*,
secure_devices_pin=None,
should_expose=None,
entity_config=None,
hass=None,
): ):
"""Initialize config.""" """Initialize config."""
super().__init__(hass)
self._should_expose = should_expose self._should_expose = should_expose
self._secure_devices_pin = secure_devices_pin self._secure_devices_pin = secure_devices_pin
self._entity_config = entity_config or {} self._entity_config = entity_config or {}

View file

@ -1,6 +1,7 @@
"""The tests for google-assistant init.""" """The tests for google-assistant init."""
import asyncio import asyncio
from homeassistant.core import Context
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.components import google_assistant as ga from homeassistant.components import google_assistant as ga
@ -20,7 +21,10 @@ def test_request_sync_service(aioclient_mock, hass):
assert aioclient_mock.call_count == 0 assert aioclient_mock.call_count == 0
yield from hass.services.async_call( yield from hass.services.async_call(
ga.const.DOMAIN, ga.const.SERVICE_REQUEST_SYNC, blocking=True ga.const.DOMAIN,
ga.const.SERVICE_REQUEST_SYNC,
blocking=True,
context=Context(user_id="123"),
) )
assert aioclient_mock.call_count == 1 assert aioclient_mock.call_count == 1

View file

@ -0,0 +1,47 @@
"""Test Google report state."""
from unittest.mock import patch
from homeassistant.components.google_assistant.report_state import (
async_enable_report_state,
)
from . import BASIC_CONFIG
from tests.common import mock_coro
async def test_report_state(hass):
"""Test report state works."""
unsub = async_enable_report_state(hass, BASIC_CONFIG)
with patch.object(
BASIC_CONFIG, "async_report_state", side_effect=mock_coro
) as mock_report:
hass.states.async_set("light.kitchen", "on")
await hass.async_block_till_done()
assert len(mock_report.mock_calls) == 1
assert mock_report.mock_calls[0][1][0] == {
"devices": {"states": {"light.kitchen": {"on": True, "online": True}}}
}
# Test that state changes that change something that Google doesn't care about
# do not trigger a state report.
with patch.object(
BASIC_CONFIG, "async_report_state", side_effect=mock_coro
) as mock_report:
hass.states.async_set(
"light.kitchen", "on", {"irrelevant": "should_be_ignored"}
)
await hass.async_block_till_done()
assert len(mock_report.mock_calls) == 0
unsub()
with patch.object(
BASIC_CONFIG, "async_report_state", side_effect=mock_coro
) as mock_report:
hass.states.async_set("light.kitchen", "on")
await hass.async_block_till_done()
assert len(mock_report.mock_calls) == 0

View file

@ -657,14 +657,20 @@ async def test_device_media_player(hass, device_class, google_type):
async def test_query_disconnect(hass): async def test_query_disconnect(hass):
"""Test a disconnect message.""" """Test a disconnect message."""
result = await sh.async_handle_message( config = MockConfig(hass=hass)
hass, config.async_enable_report_state()
BASIC_CONFIG, assert config._unsub_report_state is not None
"test-agent", with patch.object(
{"inputs": [{"intent": "action.devices.DISCONNECT"}], "requestId": REQ_ID}, config, "async_deactivate_report_state", side_effect=mock_coro
) ) as mock_deactivate:
result = await sh.async_handle_message(
hass,
config,
"test-agent",
{"inputs": [{"intent": "action.devices.DISCONNECT"}], "requestId": REQ_ID},
)
assert result is None assert result is None
assert len(mock_deactivate.mock_calls) == 1
async def test_trait_execute_adding_query_data(hass): async def test_trait_execute_adding_query_data(hass):