From cba8333a1389410fce11aaf8afbdf3e358a6a871 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 1 Jun 2018 07:44:58 -0700 Subject: [PATCH] Change nest to cloud push (#14656) * Change nest component to Cloud Push Change sensors.nest, binary_sensors.nest and climate.nest to push mode nest camera still need poll to update snapshot image Also change nest component to async * Flake8 lint * Fix async_notify_errors, it is not a coroutine * Fix pylint * Fix pylint, function name should shall shorter than 32 * Use dispatcher helper instead event bus * Use async_update_ha_state(True) * Refactoring load_platform Move service registration into async_setup_nest(), resolve an issue that before the first time configuration done, set_mode service should not be registered * Fix an issue that authorization failure may leave a blocked thread * Pylinting * async_nest_update_callback => async_update_state to avoid confusion * Move signal handler register to async_added_to_hass * Better handle nest api error * Remove unnecessary register for binary_sensor * Remove unused import * Upgrade to python-nest 4.0.1 Fix a thread race condition issue * Address my own comments * Address my own comment --- .../components/binary_sensor/nest.py | 2 +- homeassistant/components/climate/nest.py | 40 +++-- homeassistant/components/nest.py | 150 ++++++++++++------ homeassistant/components/sensor/nest.py | 18 ++- requirements_all.txt | 2 +- 5 files changed, 149 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/binary_sensor/nest.py b/homeassistant/components/binary_sensor/nest.py index 2a1732cd9f0..008b6eed1e4 100644 --- a/homeassistant/components/binary_sensor/nest.py +++ b/homeassistant/components/binary_sensor/nest.py @@ -8,9 +8,9 @@ from itertools import chain import logging from homeassistant.components.binary_sensor import (BinarySensorDevice) +from homeassistant.components.nest import DATA_NEST from homeassistant.components.sensor.nest import NestSensor from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.components.nest import DATA_NEST DEPENDENCIES = ['nest'] diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index 28e8020ab90..696f1479c08 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -8,7 +8,7 @@ import logging import voluptuous as vol -from homeassistant.components.nest import DATA_NEST +from homeassistant.components.nest import DATA_NEST, SIGNAL_NEST_UPDATE from homeassistant.components.climate import ( STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_ECO, ClimateDevice, PLATFORM_SCHEMA, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -18,6 +18,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF, STATE_UNKNOWN) +from homeassistant.helpers.dispatcher import async_dispatcher_connect DEPENDENCIES = ['nest'] _LOGGER = logging.getLogger(__name__) @@ -37,11 +38,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): temp_unit = hass.config.units.temperature_unit - add_devices( - [NestThermostat(structure, device, temp_unit) - for structure, device in hass.data[DATA_NEST].thermostats()], - True - ) + all_devices = [NestThermostat(structure, device, temp_unit) + for structure, device in hass.data[DATA_NEST].thermostats()] + + add_devices(all_devices, True) class NestThermostat(ClimateDevice): @@ -97,6 +97,20 @@ class NestThermostat(ClimateDevice): self._min_temperature = None self._max_temperature = None + @property + def should_poll(self): + """Do not need poll thanks using Nest streaming API.""" + return False + + async def async_added_to_hass(self): + """Register update signal handler.""" + async def async_update_state(): + """Update device state.""" + await self.async_update_ha_state(True) + + async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, + async_update_state) + @property def supported_features(self): """Return the list of supported features.""" @@ -170,18 +184,24 @@ class NestThermostat(ClimateDevice): def set_temperature(self, **kwargs): """Set new target temperature.""" import nest + temp = None target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) if self._mode == NEST_MODE_HEAT_COOL: if target_temp_low is not None and target_temp_high is not None: temp = (target_temp_low, target_temp_high) + _LOGGER.debug("Nest set_temperature-output-value=%s", temp) else: temp = kwargs.get(ATTR_TEMPERATURE) - _LOGGER.debug("Nest set_temperature-output-value=%s", temp) + _LOGGER.debug("Nest set_temperature-output-value=%s", temp) try: - self.device.target = temp - except nest.nest.APIError: - _LOGGER.error("An error occurred while setting the temperature") + if temp is not None: + self.device.target = temp + except nest.nest.APIError as api_error: + _LOGGER.error("An error occurred while setting temperature: %s", + api_error) + # restore target temperature + self.schedule_update_ha_state(True) def set_operation_mode(self, operation_mode): """Set operation mode.""" diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index f474bfa7a26..365f0593c8d 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -4,18 +4,20 @@ Support for Nest devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/nest/ """ +from concurrent.futures import ThreadPoolExecutor import logging import socket import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import discovery from homeassistant.const import ( CONF_STRUCTURE, CONF_FILENAME, CONF_BINARY_SENSORS, CONF_SENSORS, - CONF_MONITORED_CONDITIONS) + CONF_MONITORED_CONDITIONS, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import discovery, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['python-nest==4.0.0'] +REQUIREMENTS = ['python-nest==4.0.1'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -24,6 +26,8 @@ DOMAIN = 'nest' DATA_NEST = 'nest' +SIGNAL_NEST_UPDATE = 'nest_update' + NEST_CONFIG_FILE = 'nest.conf' CONF_CLIENT_ID = 'client_id' CONF_CLIENT_SECRET = 'client_secret' @@ -51,23 +55,44 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -def request_configuration(nest, hass, config): +async def async_nest_update_event_broker(hass, nest): + """ + Dispatch SIGNAL_NEST_UPDATE to devices when nest stream API received data. + + nest.update_event.wait will block the thread in most of time, + so specific an executor to save default thread pool. + """ + _LOGGER.debug("listening nest.update_event") + with ThreadPoolExecutor(max_workers=1) as executor: + while True: + await hass.loop.run_in_executor(executor, nest.update_event.wait) + if hass.is_running: + nest.update_event.clear() + _LOGGER.debug("dispatching nest data update") + async_dispatcher_send(hass, SIGNAL_NEST_UPDATE) + else: + return + + +async def async_request_configuration(nest, hass, config): """Request configuration steps from the user.""" configurator = hass.components.configurator if 'nest' in _CONFIGURING: _LOGGER.debug("configurator failed") - configurator.notify_errors( + configurator.async_notify_errors( _CONFIGURING['nest'], "Failed to configure, please try again.") return - def nest_configuration_callback(data): + async def async_nest_config_callback(data): """Run when the configuration callback is called.""" _LOGGER.debug("configurator callback") pin = data.get('pin') - setup_nest(hass, nest, config, pin=pin) + if await async_setup_nest(hass, nest, config, pin=pin): + # start nest update event listener as we missed startup hook + hass.async_add_job(async_nest_update_event_broker, hass, nest) - _CONFIGURING['nest'] = configurator.request_config( - "Nest", nest_configuration_callback, + _CONFIGURING['nest'] = configurator.async_request_config( + "Nest", async_nest_config_callback, description=('To configure Nest, click Request Authorization below, ' 'log into your Nest account, ' 'and then enter the resulting PIN'), @@ -78,60 +103,47 @@ def request_configuration(nest, hass, config): ) -def setup_nest(hass, nest, config, pin=None): +async def async_setup_nest(hass, nest, config, pin=None): """Set up the Nest devices.""" + from nest.nest import AuthorizationError, APIError if pin is not None: _LOGGER.debug("pin acquired, requesting access token") - nest.request_token(pin) + error_message = None + try: + nest.request_token(pin) + except AuthorizationError as auth_error: + error_message = "Nest authorization failed: {}".format(auth_error) + except APIError as api_error: + error_message = "Failed to call Nest API: {}".format(api_error) + + if error_message is not None: + _LOGGER.warning(error_message) + hass.components.configurator.async_notify_errors( + _CONFIGURING['nest'], error_message) + return False if nest.access_token is None: _LOGGER.debug("no access_token, requesting configuration") - request_configuration(nest, hass, config) - return + await async_request_configuration(nest, hass, config) + return False if 'nest' in _CONFIGURING: _LOGGER.debug("configuration done") configurator = hass.components.configurator - configurator.request_done(_CONFIGURING.pop('nest')) + configurator.async_request_done(_CONFIGURING.pop('nest')) _LOGGER.debug("proceeding with setup") conf = config[DOMAIN] hass.data[DATA_NEST] = NestDevice(hass, conf, nest) - _LOGGER.debug("proceeding with discovery") - discovery.load_platform(hass, 'climate', DOMAIN, {}, config) - discovery.load_platform(hass, 'camera', DOMAIN, {}, config) - - sensor_config = conf.get(CONF_SENSORS, {}) - discovery.load_platform(hass, 'sensor', DOMAIN, sensor_config, config) - - binary_sensor_config = conf.get(CONF_BINARY_SENSORS, {}) - discovery.load_platform(hass, 'binary_sensor', DOMAIN, - binary_sensor_config, config) - - _LOGGER.debug("setup done") - - return True - - -def setup(hass, config): - """Set up the Nest thermostat component.""" - import nest - - if 'nest' in _CONFIGURING: - return - - conf = config[DOMAIN] - client_id = conf[CONF_CLIENT_ID] - client_secret = conf[CONF_CLIENT_SECRET] - filename = config.get(CONF_FILENAME, NEST_CONFIG_FILE) - - access_token_cache_file = hass.config.path(filename) - - nest = nest.Nest( - access_token_cache_file=access_token_cache_file, - client_id=client_id, client_secret=client_secret) - setup_nest(hass, nest, config) + for component, discovered in [ + ('climate', {}), + ('camera', {}), + ('sensor', conf.get(CONF_SENSORS, {})), + ('binary_sensor', conf.get(CONF_BINARY_SENSORS, {}))]: + _LOGGER.debug("proceeding with discovery -- %s", component) + hass.async_add_job(discovery.async_load_platform, + hass, component, DOMAIN, discovered, config) def set_mode(service): """Set the home/away mode for a Nest structure.""" @@ -148,9 +160,47 @@ def setup(hass, config): _LOGGER.error("Invalid structure %s", service.data[ATTR_STRUCTURE]) - hass.services.register( + hass.services.async_register( DOMAIN, 'set_mode', set_mode, schema=AWAY_SCHEMA) + def start_up(event): + """Start Nest update event listener.""" + hass.async_add_job(async_nest_update_event_broker, hass, nest) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_up) + + def shut_down(event): + """Stop Nest update event listener.""" + if nest: + nest.update_event.set() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shut_down) + + _LOGGER.debug("async_setup_nest is done") + + return True + + +async def async_setup(hass, config): + """Set up Nest components.""" + from nest import Nest + + if 'nest' in _CONFIGURING: + return + + conf = config[DOMAIN] + client_id = conf[CONF_CLIENT_ID] + client_secret = conf[CONF_CLIENT_SECRET] + filename = config.get(CONF_FILENAME, NEST_CONFIG_FILE) + + access_token_cache_file = hass.config.path(filename) + + nest = Nest( + access_token_cache_file=access_token_cache_file, + client_id=client_id, client_secret=client_secret) + + await async_setup_nest(hass, nest, config) + return True diff --git a/homeassistant/components/sensor/nest.py b/homeassistant/components/sensor/nest.py index 0de2e2e0cdb..46a2206a9f7 100644 --- a/homeassistant/components/sensor/nest.py +++ b/homeassistant/components/sensor/nest.py @@ -7,13 +7,15 @@ https://home-assistant.io/components/sensor.nest/ from itertools import chain import logging -from homeassistant.components.nest import DATA_NEST +from homeassistant.components.nest import DATA_NEST, SIGNAL_NEST_UPDATE +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_MONITORED_CONDITIONS, DEVICE_CLASS_TEMPERATURE) DEPENDENCIES = ['nest'] + SENSOR_TYPES = ['humidity', 'operation_mode', 'hvac_state'] @@ -130,6 +132,20 @@ class NestSensor(Entity): """Return the unit the value is expressed in.""" return self._unit + @property + def should_poll(self): + """Do not need poll thanks using Nest streaming API.""" + return False + + async def async_added_to_hass(self): + """Register update signal handler.""" + async def async_update_state(): + """Update sensor state.""" + await self.async_update_ha_state(True) + + async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, + async_update_state) + class NestBasicSensor(NestSensor): """Representation a basic Nest sensor.""" diff --git a/requirements_all.txt b/requirements_all.txt index 1c2f927eaf5..711defddb6e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1033,7 +1033,7 @@ python-mpd2==1.0.0 python-mystrom==0.4.2 # homeassistant.components.nest -python-nest==4.0.0 +python-nest==4.0.1 # homeassistant.components.device_tracker.nmap_tracker python-nmap==0.6.1