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)
This commit is contained in:
David Bonnes 2019-09-01 11:45:41 +01:00 committed by GitHub
parent 298aafc79d
commit f91dd4f5f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 135 additions and 143 deletions

View file

@ -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()

View file

@ -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))

View file

@ -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"]

View file

@ -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)
)

View file

@ -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

View file

@ -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