From f91dd4f5f859ed6aafc7b78d41cdfcdb34347418 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 1 Sep 2019 11:45:41 +0100 Subject: [PATCH] Change evohome to asyncio client (#26042) * fully async now * add convergence (call update() 2 seconds after client API call) (issue#25400) * handle dead TRVs (e.g. flat battery) --- homeassistant/components/evohome/__init__.py | 146 +++++++++--------- homeassistant/components/evohome/climate.py | 94 +++++------ .../components/evohome/manifest.json | 2 +- .../components/evohome/water_heater.py | 31 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 3 - 6 files changed, 135 insertions(+), 143 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 05308782362..adb6e856984 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -2,14 +2,13 @@ Such systems include evohome (multi-zone), and Round Thermostat (single zone). """ -import asyncio from datetime import datetime, timedelta import logging from typing import Any, Dict, Optional, Tuple -import requests.exceptions +import aiohttp.client_exceptions import voluptuous as vol -import evohomeclient2 +import evohomeasync2 from homeassistant.const import ( CONF_ACCESS_TOKEN, @@ -21,17 +20,10 @@ from homeassistant.const import ( TEMP_CELSIUS, ) from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import ( - async_track_point_in_utc_time, - track_time_interval, -) from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util.dt import parse_datetime, utcnow @@ -81,55 +73,60 @@ def _handle_exception(err) -> bool: try: raise err - except evohomeclient2.AuthenticationError: + except evohomeasync2.AuthenticationError: _LOGGER.error( "Failed to (re)authenticate with the vendor's server. " + "Check your network and the vendor's service status page. " "Check that your username and password are correct. " "Message is: %s", err, ) return False - except requests.exceptions.ConnectionError: + except aiohttp.ClientConnectionError: # this appears to be common with Honeywell's servers _LOGGER.warning( "Unable to connect with the vendor's server. " - "Check your network and the vendor's status page." + "Check your network and the vendor's service status page. " "Message is: %s", err, ) return False - except requests.exceptions.HTTPError: - if err.response.status_code == HTTP_SERVICE_UNAVAILABLE: + except aiohttp.ClientResponseError: + if err.status == HTTP_SERVICE_UNAVAILABLE: _LOGGER.warning( - "Vendor says their server is currently unavailable. " - "Check the vendor's status page." + "The vendor says their server is currently unavailable. " + "Check the vendor's service status page." ) return False - if err.response.status_code == HTTP_TOO_MANY_REQUESTS: + if err.status == HTTP_TOO_MANY_REQUESTS: _LOGGER.warning( "The vendor's API rate limit has been exceeded. " - "Consider increasing the %s.", + "If this message persists, consider increasing the %s.", CONF_SCAN_INTERVAL, ) return False - raise # we don't expect/handle any other HTTPErrors + raise # we don't expect/handle any other ClientResponseError -def setup(hass: HomeAssistantType, hass_config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Create a (EMEA/EU-based) Honeywell evohome system.""" - broker = EvoBroker(hass, hass_config[DOMAIN]) - if not broker.init_client(): + broker = EvoBroker(hass, config[DOMAIN]) + if not await broker.init_client(): return False - load_platform(hass, "climate", DOMAIN, {}, hass_config) + hass.async_create_task(async_load_platform(hass, "climate", DOMAIN, {}, config)) if broker.tcs.hotwater: - load_platform(hass, "water_heater", DOMAIN, {}, hass_config) + hass.async_create_task( + async_load_platform(hass, "water_heater", DOMAIN, {}, config) + ) - track_time_interval(hass, broker.update, hass_config[DOMAIN][CONF_SCAN_INTERVAL]) + hass.helpers.event.async_track_time_interval( + broker.update, config[DOMAIN][CONF_SCAN_INTERVAL] + ) return True @@ -141,8 +138,7 @@ class EvoBroker: """Initialize the evohome client and data structure.""" self.hass = hass self.params = params - - self.config = self.status = self.timers = {} + self.config = {} self.client = self.tcs = None self._app_storage = {} @@ -150,32 +146,31 @@ class EvoBroker: hass.data[DOMAIN] = {} hass.data[DOMAIN]["broker"] = self - def init_client(self) -> bool: + async def init_client(self) -> bool: """Initialse the evohome data broker. Return True if this is successful, otherwise return False. """ - refresh_token, access_token, access_token_expires = asyncio.run_coroutine_threadsafe( - self._load_auth_tokens(), self.hass.loop - ).result() + refresh_token, access_token, access_token_expires = ( + await self._load_auth_tokens() + ) - # evohomeclient2 uses naive/local datetimes + # evohomeasync2 uses naive/local datetimes if access_token_expires is not None: access_token_expires = _utc_to_local_dt(access_token_expires) - try: - client = self.client = evohomeclient2.EvohomeClient( - self.params[CONF_USERNAME], - self.params[CONF_PASSWORD], - refresh_token=refresh_token, - access_token=access_token, - access_token_expires=access_token_expires, - ) + client = self.client = evohomeasync2.EvohomeClient( + self.params[CONF_USERNAME], + self.params[CONF_PASSWORD], + refresh_token=refresh_token, + access_token=access_token, + access_token_expires=access_token_expires, + session=async_get_clientsession(self.hass), + ) - except ( - requests.exceptions.RequestException, - evohomeclient2.AuthenticationError, - ) as err: + try: + await client.login() + except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: if not _handle_exception(err): return False @@ -200,17 +195,14 @@ class EvoBroker: return False self.tcs = ( - client.locations[loc_idx] # noqa: E501; pylint: disable=protected-access + client.locations[loc_idx] # pylint: disable=protected-access ._gateways[0] ._control_systems[0] ) _LOGGER.debug("Config = %s", self.config) - if _LOGGER.isEnabledFor(logging.DEBUG): - # don't do an I/O unless required - _LOGGER.debug( - "Status = %s", client.locations[loc_idx].status()[GWS][0][TCS][0] - ) + if _LOGGER.isEnabledFor(logging.DEBUG): # don't do an I/O unless required + await self.update() # includes: _LOGGER.debug("Status = %s"... return True @@ -237,7 +229,7 @@ class EvoBroker: return (None, None, None) # account switched: so tokens wont be valid async def _save_auth_tokens(self, *args) -> None: - # evohomeclient2 uses naive/local datetimes + # evohomeasync2 uses naive/local datetimes access_token_expires = _local_dt_to_utc(self.client.access_token_expires) self._app_storage[CONF_USERNAME] = self.params[CONF_USERNAME] @@ -248,13 +240,12 @@ class EvoBroker: store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) await store.async_save(self._app_storage) - async_track_point_in_utc_time( - self.hass, + self.hass.helpers.event.async_track_point_in_utc_time( self._save_auth_tokens, access_token_expires + self.params[CONF_SCAN_INTERVAL], ) - def update(self, *args, **kwargs) -> None: + async def update(self, *args, **kwargs) -> None: """Get the latest state data of the entire evohome Location. This includes state data for the Controller and all its child devices, @@ -264,19 +255,16 @@ class EvoBroker: loc_idx = self.params[CONF_LOCATION_IDX] try: - status = self.client.locations[loc_idx].status()[GWS][0][TCS][0] - except ( - requests.exceptions.RequestException, - evohomeclient2.AuthenticationError, - ) as err: + status = await self.client.locations[loc_idx].status() + except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: _handle_exception(err) else: - self.timers["statusUpdated"] = utcnow() - - _LOGGER.debug("Status = %s", status) - # inform the evohome devices that state data has been updated - async_dispatcher_send(self.hass, DOMAIN, {"signal": "refresh"}) + self.hass.helpers.dispatcher.async_dispatcher_send( + DOMAIN, {"signal": "refresh"} + ) + + _LOGGER.debug("Status = %s", status[GWS][0][TCS][0]) class EvoDevice(Entity): @@ -289,6 +277,7 @@ class EvoDevice(Entity): def __init__(self, evo_broker, evo_device) -> None: """Initialize the evohome entity.""" self._evo_device = evo_device + self._evo_broker = evo_broker self._evo_tcs = evo_broker.tcs self._name = self._icon = self._precision = None @@ -387,7 +376,7 @@ class EvoDevice(Entity): async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" - async_dispatcher_connect(self.hass, DOMAIN, self._refresh) + self.hass.helpers.dispatcher.async_dispatcher_connect(DOMAIN, self._refresh) @property def precision(self) -> float: @@ -399,14 +388,27 @@ class EvoDevice(Entity): """Return the temperature unit to use in the frontend UI.""" return TEMP_CELSIUS - def _update_schedule(self) -> None: + async def _call_client_api(self, api_function) -> None: + try: + await api_function + except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + _handle_exception(err) + + self.hass.helpers.event.async_call_later( + 2, self._evo_broker.update() + ) # call update() in 2 seconds + + async def _update_schedule(self) -> None: """Get the latest state data.""" if ( not self._schedule.get("DailySchedules") or parse_datetime(self.setpoints["next"]["from"]) < utcnow() ): - self._schedule = self._evo_device.schedule() + try: + self._schedule = await self._evo_device.schedule() + except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + _handle_exception(err) - def update(self) -> None: + async def async_update(self) -> None: """Get the latest state data.""" - self._update_schedule() + await self._update_schedule() diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index d1b9d5f54c7..0264f76f38f 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -3,9 +3,6 @@ from datetime import datetime import logging from typing import Any, Dict, Optional, List -import requests.exceptions -import evohomeclient2 - from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( CURRENT_HVAC_HEAT, @@ -25,7 +22,7 @@ from homeassistant.const import PRECISION_TENTHS from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util.dt import parse_datetime -from . import CONF_LOCATION_IDX, _handle_exception, EvoDevice +from . import CONF_LOCATION_IDX, EvoDevice from .const import ( DOMAIN, EVO_RESET, @@ -65,10 +62,13 @@ EVO_PRESET_TO_HA = { HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} -def setup_platform( - hass: HomeAssistantType, hass_config: ConfigType, add_entities, discovery_info=None +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None ) -> None: """Create the evohome Controller, and its Zones, if any.""" + if discovery_info is None: + return + broker = hass.data[DOMAIN]["broker"] loc_idx = broker.params[CONF_LOCATION_IDX] @@ -91,7 +91,7 @@ def setup_platform( zone.name, ) - add_entities([EvoThermostat(broker, zone)], update_before_add=True) + async_add_entities([EvoThermostat(broker, zone)], update_before_add=True) return controller = EvoController(broker, broker.tcs) @@ -107,7 +107,7 @@ def setup_platform( ) zones.append(EvoZone(broker, zone)) - add_entities([controller] + zones, update_before_add=True) + async_add_entities([controller] + zones, update_before_add=True) class EvoClimateDevice(EvoDevice, ClimateDevice): @@ -119,22 +119,18 @@ class EvoClimateDevice(EvoDevice, ClimateDevice): self._preset_modes = None - def _set_temperature( + async def _set_temperature( self, temperature: float, until: Optional[datetime] = None ) -> None: """Set a new target temperature for the Zone. until == None means indefinitely (i.e. PermanentOverride) """ - try: + await self._call_client_api( self._evo_device.set_temperature(temperature, until) - except ( - requests.exceptions.RequestException, - evohomeclient2.AuthenticationError, - ) as err: - _handle_exception(err) + ) - def _set_zone_mode(self, op_mode: str) -> None: + async def _set_zone_mode(self, op_mode: str) -> None: """Set a Zone to one of its native EVO_* operating modes. Zones inherit their _effective_ operating mode from the Controller. @@ -153,35 +149,24 @@ class EvoClimateDevice(EvoDevice, ClimateDevice): (by default) 5C, and 'Away', Zones to (by default) 12C. """ if op_mode == EVO_FOLLOW: - try: - self._evo_device.cancel_temp_override() - except ( - requests.exceptions.RequestException, - evohomeclient2.AuthenticationError, - ) as err: - _handle_exception(err) + await self._call_client_api(self._evo_device.cancel_temp_override()) return temperature = self._evo_device.setpointStatus["targetHeatTemperature"] until = None # EVO_PERMOVER if op_mode == EVO_TEMPOVER and self._schedule["DailySchedules"]: - self._update_schedule() + await self._update_schedule() if self._schedule["DailySchedules"]: until = parse_datetime(self.setpoints["next"]["from"]) - self._set_temperature(temperature, until=until) + await self._set_temperature(temperature, until=until) - def _set_tcs_mode(self, op_mode: str) -> None: + async def _set_tcs_mode(self, op_mode: str) -> None: """Set the Controller to any of its native EVO_* operating modes.""" - try: - # noqa: E501; pylint: disable=protected-access - self._evo_tcs._set_status(op_mode) - except ( - requests.exceptions.RequestException, - evohomeclient2.AuthenticationError, - ) as err: - _handle_exception(err) + await self._call_client_api( + self._evo_tcs._set_status(op_mode) # pylint: disable=protected-access + ) @property def hvac_modes(self) -> List[str]: @@ -216,6 +201,11 @@ class EvoZone(EvoClimateDevice): self._supported_features = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE self._preset_modes = list(HA_PRESET_TO_EVO) + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._evo_device.temperatureStatus["isAvailable"] + @property def hvac_mode(self) -> str: """Return the current operating mode of the evohome Zone.""" @@ -276,28 +266,28 @@ class EvoZone(EvoClimateDevice): """ return self._evo_device.setpointCapabilities["maxHeatSetpoint"] - def set_temperature(self, **kwargs) -> None: + async def async_set_temperature(self, **kwargs) -> None: """Set a new target temperature.""" until = kwargs.get("until") if until: until = parse_datetime(until) - self._set_temperature(kwargs["temperature"], until) + await self._set_temperature(kwargs["temperature"], until) - def set_hvac_mode(self, hvac_mode: str) -> None: + async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set an operating mode for the Zone.""" if hvac_mode == HVAC_MODE_OFF: - self._set_temperature(self.min_temp, until=None) + await self._set_temperature(self.min_temp, until=None) else: # HVAC_MODE_HEAT - self._set_zone_mode(EVO_FOLLOW) + await self._set_zone_mode(EVO_FOLLOW) - def set_preset_mode(self, preset_mode: Optional[str]) -> None: + async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: """Set a new preset mode. If preset_mode is None, then revert to following the schedule. """ - self._set_zone_mode(HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW)) + await self._set_zone_mode(HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW)) class EvoController(EvoClimateDevice): @@ -344,25 +334,25 @@ class EvoController(EvoClimateDevice): """Return the current preset mode, e.g., home, away, temp.""" return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) - def set_temperature(self, **kwargs) -> None: + async def async_set_temperature(self, **kwargs) -> None: """Do nothing. The evohome Controller doesn't have a target temperature. """ return - def set_hvac_mode(self, hvac_mode: str) -> None: + async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set an operating mode for the Controller.""" - self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode)) + await self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode)) - def set_preset_mode(self, preset_mode: Optional[str]) -> None: + async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: """Set a new preset mode. If preset_mode is None, then revert to 'Auto' mode. """ - self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO)) + await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO)) - def update(self) -> None: + async def async_update(self) -> None: """Get the latest state data.""" return @@ -409,16 +399,16 @@ class EvoThermostat(EvoZone): return super().preset_mode - def set_hvac_mode(self, hvac_mode: str) -> None: + async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set an operating mode.""" - self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode)) + await self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode)) - def set_preset_mode(self, preset_mode: Optional[str]) -> None: + async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: """Set a new preset mode. If preset_mode is None, then revert to following the schedule. """ if preset_mode in list(HA_PRESET_TO_TCS): - self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode)) + await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode)) else: - self._set_zone_mode(HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW)) + await self._set_zone_mode(HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW)) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 078d4ace776..32a57cf20b1 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -3,7 +3,7 @@ "name": "Evohome", "documentation": "https://www.home-assistant.io/components/evohome", "requirements": [ - "evohomeclient==0.3.3" + "evohome-async==0.3.3b4" ], "dependencies": [], "codeowners": ["@zxdavb"] diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 6309f07a000..1b37bc3b2b5 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -2,9 +2,6 @@ import logging from typing import List -import requests.exceptions -import evohomeclient2 - from homeassistant.components.water_heater import ( SUPPORT_OPERATION_MODE, WaterHeaterDevice, @@ -12,7 +9,7 @@ from homeassistant.components.water_heater import ( from homeassistant.const import PRECISION_WHOLE, STATE_OFF, STATE_ON from homeassistant.util.dt import parse_datetime -from . import _handle_exception, EvoDevice +from . import EvoDevice from .const import DOMAIN, EVO_STRFTIME, EVO_FOLLOW, EVO_TEMPOVER, EVO_PERMOVER _LOGGER = logging.getLogger(__name__) @@ -23,8 +20,13 @@ EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items()} HA_OPMODE_TO_DHW = {STATE_ON: EVO_FOLLOW, STATE_OFF: EVO_PERMOVER} -def setup_platform(hass, hass_config, add_entities, discovery_info=None) -> None: +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None +) -> None: """Create the DHW controller.""" + if discovery_info is None: + return + broker = hass.data[DOMAIN]["broker"] _LOGGER.debug( @@ -33,7 +35,7 @@ def setup_platform(hass, hass_config, add_entities, discovery_info=None) -> None evo_dhw = EvoDHW(broker, broker.tcs.hotwater) - add_entities([evo_dhw], update_before_add=True) + async_add_entities([evo_dhw], update_before_add=True) class EvoDHW(EvoDevice, WaterHeaterDevice): @@ -58,6 +60,11 @@ class EvoDHW(EvoDevice, WaterHeaterDevice): self._supported_features = SUPPORT_OPERATION_MODE self._operation_list = list(HA_OPMODE_TO_DHW) + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._evo_device.temperatureStatus.get("isAvailable", False) + @property def current_operation(self) -> str: """Return the current operating mode (On, or Off).""" @@ -73,7 +80,7 @@ class EvoDHW(EvoDevice, WaterHeaterDevice): """Return the current temperature.""" return self._evo_device.temperatureStatus["temperature"] - def set_operation_mode(self, operation_mode: str) -> None: + async def async_set_operation_mode(self, operation_mode: str) -> None: """Set new operation mode for a DHW controller.""" op_mode = HA_OPMODE_TO_DHW[operation_mode] @@ -81,17 +88,13 @@ class EvoDHW(EvoDevice, WaterHeaterDevice): until = None # EVO_FOLLOW, EVO_PERMOVER if op_mode == EVO_TEMPOVER and self._schedule["DailySchedules"]: - self._update_schedule() + await self._update_schedule() if self._schedule["DailySchedules"]: until = parse_datetime(self.setpoints["next"]["from"]) until = until.strftime(EVO_STRFTIME) data = {"Mode": op_mode, "State": state, "UntilTime": until} - try: + await self._call_client_api( self._evo_device._set_dhw(data) # pylint: disable=protected-access - except ( - requests.exceptions.RequestException, - evohomeclient2.AuthenticationError, - ) as err: - _handle_exception(err) + ) diff --git a/requirements_all.txt b/requirements_all.txt index aa9daad808e..76ffb996fa5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -461,7 +461,7 @@ eternalegypt==0.0.10 # evdev==0.6.1 # homeassistant.components.evohome -evohomeclient==0.3.3 +evohome-async==0.3.3b4 # homeassistant.components.dlib_face_detect # homeassistant.components.dlib_face_identify diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 35125387e89..2111149cb55 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -123,9 +123,6 @@ enocean==0.50 # homeassistant.components.season ephem==3.7.6.0 -# homeassistant.components.evohome -evohomeclient==0.3.3 - # homeassistant.components.feedreader feedparser-homeassistant==5.2.2.dev1