Add evohome high_precision temperatures (#27513)

* add high_precision (current) temperatures 
* bump client to use aiohttp for v1 client
* token saving now event-driven rather than scheduled
* protection against invalid tokens that cause issues
* tweak error message
This commit is contained in:
David Bonnes 2019-10-16 10:32:25 +01:00 committed by GitHub
parent 5a35e52adf
commit 44b6258e48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 176 additions and 125 deletions

View File

@ -10,9 +10,9 @@ from typing import Any, Dict, Optional, Tuple
import aiohttp.client_exceptions import aiohttp.client_exceptions
import voluptuous as vol import voluptuous as vol
import evohomeasync2 import evohomeasync2
import evohomeasync
from homeassistant.const import ( from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_PASSWORD, CONF_PASSWORD,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
CONF_USERNAME, CONF_USERNAME,
@ -32,10 +32,13 @@ from .const import DOMAIN, EVO_FOLLOW, STORAGE_VERSION, STORAGE_KEY, GWS, TCS
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires" ACCESS_TOKEN = "access_token"
CONF_REFRESH_TOKEN = "refresh_token" ACCESS_TOKEN_EXPIRES = "access_token_expires"
REFRESH_TOKEN = "refresh_token"
USER_DATA = "user_data"
CONF_LOCATION_IDX = "location_idx" CONF_LOCATION_IDX = "location_idx"
SCAN_INTERVAL_DEFAULT = timedelta(seconds=300) SCAN_INTERVAL_DEFAULT = timedelta(seconds=300)
SCAN_INTERVAL_MINIMUM = timedelta(seconds=60) SCAN_INTERVAL_MINIMUM = timedelta(seconds=60)
@ -96,14 +99,15 @@ def convert_dict(dictionary: Dict[str, Any]) -> Dict[str, Any]:
def _handle_exception(err) -> bool: def _handle_exception(err) -> bool:
"""Return False if the exception can't be ignored."""
try: try:
raise err raise err
except evohomeasync2.AuthenticationError: except evohomeasync2.AuthenticationError:
_LOGGER.error( _LOGGER.error(
"Failed to (re)authenticate with the vendor's server. " "Failed to authenticate with the vendor's server. "
"Check your network and the vendor's service status page. " "Check your network and the vendor's service status page. "
"Check that your username and password are correct. " "Also check that your username and password are correct. "
"Message is: %s", "Message is: %s",
err, err,
) )
@ -135,14 +139,77 @@ def _handle_exception(err) -> bool:
) )
return False return False
raise # we don't expect/handle any other ClientResponseError raise # we don't expect/handle any other Exceptions
async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
"""Create a (EMEA/EU-based) Honeywell evohome system.""" """Create a (EMEA/EU-based) Honeywell evohome system."""
broker = EvoBroker(hass, config[DOMAIN])
if not await broker.init_client(): async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]:
app_storage = await store.async_load()
tokens = dict(app_storage if app_storage else {})
if tokens.pop(CONF_USERNAME, None) != config[DOMAIN][CONF_USERNAME]:
# any tokens wont be valid, and store might be be corrupt
await store.async_save({})
return ({}, None)
# evohomeasync2 requires naive/local datetimes as strings
if tokens.get(ACCESS_TOKEN_EXPIRES) is not None:
tokens[ACCESS_TOKEN_EXPIRES] = _dt_to_local_naive(
dt_util.parse_datetime(tokens[ACCESS_TOKEN_EXPIRES])
)
user_data = tokens.pop(USER_DATA, None)
return (tokens, user_data)
store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
tokens, user_data = await load_auth_tokens(store)
client_v2 = evohomeasync2.EvohomeClient(
config[DOMAIN][CONF_USERNAME],
config[DOMAIN][CONF_PASSWORD],
**tokens,
session=async_get_clientsession(hass),
)
try:
await client_v2.login()
except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err:
_handle_exception(err)
return False return False
finally:
config[DOMAIN][CONF_PASSWORD] = "REDACTED"
loc_idx = config[DOMAIN][CONF_LOCATION_IDX]
try:
loc_config = client_v2.installation_info[loc_idx][GWS][0][TCS][0]
except IndexError:
_LOGGER.error(
"Config error: '%s' = %s, but the valid range is 0-%s. "
"Unable to continue. Fix any configuration errors and restart HA.",
CONF_LOCATION_IDX,
loc_idx,
len(client_v2.installation_info) - 1,
)
return False
_LOGGER.debug("Config = %s", loc_config)
client_v1 = evohomeasync.EvohomeClient(
client_v2.username,
client_v2.password,
user_data=user_data,
session=async_get_clientsession(hass),
)
hass.data[DOMAIN] = {}
hass.data[DOMAIN]["broker"] = broker = EvoBroker(
hass, client_v2, client_v1, store, config[DOMAIN]
)
await broker.save_auth_tokens()
await broker.update() # get initial state
hass.async_create_task(async_load_platform(hass, "climate", DOMAIN, {}, config)) hass.async_create_task(async_load_platform(hass, "climate", DOMAIN, {}, config))
if broker.tcs.hotwater: if broker.tcs.hotwater:
@ -160,116 +227,100 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
class EvoBroker: class EvoBroker:
"""Container for evohome client and data.""" """Container for evohome client and data."""
def __init__(self, hass, params) -> None: def __init__(self, hass, client, client_v1, store, params) -> None:
"""Initialize the evohome client and its data structure.""" """Initialize the evohome client and its data structure."""
self.hass = hass self.hass = hass
self.client = client
self.client_v1 = client_v1
self._store = store
self.params = params self.params = params
self.config = {}
self.client = self.tcs = None
self._app_storage = {}
hass.data[DOMAIN] = {}
hass.data[DOMAIN]["broker"] = self
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 = (
await self._load_auth_tokens()
)
# evohomeasync2 uses naive/local datetimes
if access_token_expires is not None:
access_token_expires = _dt_to_local_naive(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),
)
try:
await client.login()
except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err:
if not _handle_exception(err):
return False
finally:
self.params[CONF_PASSWORD] = "REDACTED"
self.hass.add_job(self._save_auth_tokens())
loc_idx = self.params[CONF_LOCATION_IDX]
try:
self.config = client.installation_info[loc_idx][GWS][0][TCS][0]
except IndexError:
_LOGGER.error(
"Config error: '%s' = %s, but its valid range is 0-%s. "
"Unable to continue. "
"Fix any configuration errors and restart HA.",
CONF_LOCATION_IDX,
loc_idx,
len(client.installation_info) - 1,
)
return False
loc_idx = params[CONF_LOCATION_IDX]
self.config = client.installation_info[loc_idx][GWS][0][TCS][0]
self.tcs = ( self.tcs = (
client.locations[loc_idx] # pylint: disable=protected-access client.locations[loc_idx] # pylint: disable=protected-access
._gateways[0] ._gateways[0]
._control_systems[0] ._control_systems[0]
) )
self.temps = None
_LOGGER.debug("Config = %s", self.config) async def save_auth_tokens(self) -> None:
if _LOGGER.isEnabledFor(logging.DEBUG): # don't do an I/O unless required """Save access tokens and session IDs to the store for later use."""
await self.update() # includes: _LOGGER.debug("Status = %s"...
return True
async def _load_auth_tokens(
self
) -> Tuple[Optional[str], Optional[str], Optional[datetime]]:
store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
app_storage = self._app_storage = await store.async_load()
if app_storage is None:
app_storage = self._app_storage = {}
if app_storage.get(CONF_USERNAME) == self.params[CONF_USERNAME]:
refresh_token = app_storage.get(CONF_REFRESH_TOKEN)
access_token = app_storage.get(CONF_ACCESS_TOKEN)
at_expires_str = app_storage.get(CONF_ACCESS_TOKEN_EXPIRES)
if at_expires_str:
at_expires_dt = dt_util.parse_datetime(at_expires_str)
else:
at_expires_dt = None
return (refresh_token, access_token, at_expires_dt)
return (None, None, None) # account switched: so tokens wont be valid
async def _save_auth_tokens(self, *args) -> None:
# evohomeasync2 uses naive/local datetimes # evohomeasync2 uses naive/local datetimes
access_token_expires = _local_dt_to_aware(self.client.access_token_expires) access_token_expires = _local_dt_to_aware(self.client.access_token_expires)
self._app_storage[CONF_USERNAME] = self.params[CONF_USERNAME] app_storage = {CONF_USERNAME: self.client.username}
self._app_storage[CONF_REFRESH_TOKEN] = self.client.refresh_token app_storage[REFRESH_TOKEN] = self.client.refresh_token
self._app_storage[CONF_ACCESS_TOKEN] = self.client.access_token app_storage[ACCESS_TOKEN] = self.client.access_token
self._app_storage[CONF_ACCESS_TOKEN_EXPIRES] = access_token_expires.isoformat() app_storage[ACCESS_TOKEN_EXPIRES] = access_token_expires.isoformat()
store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) if self.client_v1 and self.client_v1.user_data:
await store.async_save(self._app_storage) app_storage[USER_DATA] = {
"userInfo": {"userID": self.client_v1.user_data["userInfo"]["userID"]},
"sessionId": self.client_v1.user_data["sessionId"],
}
else:
app_storage[USER_DATA] = None
self.hass.helpers.event.async_track_point_in_utc_time( await self._store.async_save(app_storage)
self._save_auth_tokens,
access_token_expires + self.params[CONF_SCAN_INTERVAL], async def _update_v1(self, *args, **kwargs) -> None:
) """Get the latest high-precision temperatures of the default Location."""
def get_session_id(client_v1) -> Optional[str]:
user_data = client_v1.user_data if client_v1 else None
return user_data.get("sessionId") if user_data else None
session_id = get_session_id(self.client_v1)
try:
temps = list(await self.client_v1.temperatures(force_refresh=True))
except aiohttp.ClientError as err:
_LOGGER.warning(
"Unable to obtain the latest high-precision temperatures. "
"Check your network and the vendor's service status page. "
"Proceeding with low-precision temperatures. "
"Message is: %s",
err,
)
self.temps = None # these are now stale, will fall back to v2 temps
else:
if (
str(self.client_v1.location_id)
!= self.client.locations[self.params[CONF_LOCATION_IDX]].locationId
):
_LOGGER.warning(
"The v2 API's configured location doesn't match "
"the v1 API's default location (there is more than one location), "
"so the high-precision feature will be disabled"
)
self.client_v1 = self.temps = None
else:
self.temps = {str(i["id"]): i["temp"] for i in temps}
_LOGGER.debug("Temperatures = %s", self.temps)
if session_id != get_session_id(self.client_v1):
await self.save_auth_tokens()
async def _update_v2(self, *args, **kwargs) -> None:
"""Get the latest modes, temperatures, setpoints of a Location."""
access_token = self.client.access_token
loc_idx = self.params[CONF_LOCATION_IDX]
try:
status = await self.client.locations[loc_idx].status()
except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err:
_handle_exception(err)
else:
self.hass.helpers.dispatcher.async_dispatcher_send(DOMAIN)
_LOGGER.debug("Status = %s", status[GWS][0][TCS][0])
if access_token != self.client.access_token:
await self.save_auth_tokens()
async def update(self, *args, **kwargs) -> None: async def update(self, *args, **kwargs) -> None:
"""Get the latest state data of an entire evohome Location. """Get the latest state data of an entire evohome Location.
@ -278,17 +329,13 @@ class EvoBroker:
operating mode of the Controller and the current temp of its children (e.g. operating mode of the Controller and the current temp of its children (e.g.
Zones, DHW controller). Zones, DHW controller).
""" """
loc_idx = self.params[CONF_LOCATION_IDX] await self._update_v2()
try: if self.client_v1:
status = await self.client.locations[loc_idx].status() await self._update_v1()
except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err:
_handle_exception(err)
else:
# inform the evohome devices that state data has been updated
self.hass.helpers.dispatcher.async_dispatcher_send(DOMAIN)
_LOGGER.debug("Status = %s", status[GWS][0][TCS][0]) # inform the evohome devices that state data has been updated
self.hass.helpers.dispatcher.async_dispatcher_send(DOMAIN)
class EvoDevice(Entity): class EvoDevice(Entity):
@ -305,10 +352,8 @@ class EvoDevice(Entity):
self._evo_tcs = evo_broker.tcs self._evo_tcs = evo_broker.tcs
self._unique_id = self._name = self._icon = self._precision = None self._unique_id = self._name = self._icon = self._precision = None
self._device_state_attrs = {}
self._state_attributes = []
self._supported_features = None self._supported_features = None
self._device_state_attrs = {}
@callback @callback
def _refresh(self) -> None: def _refresh(self) -> None:
@ -394,9 +439,13 @@ class EvoChild(EvoDevice):
@property @property
def current_temperature(self) -> Optional[float]: def current_temperature(self) -> Optional[float]:
"""Return the current temperature of a Zone.""" """Return the current temperature of a Zone."""
if self._evo_device.temperatureStatus["isAvailable"]: if not self._evo_device.temperatureStatus["isAvailable"]:
return self._evo_device.temperatureStatus["temperature"] return None
return None
if self._evo_broker.temps:
return self._evo_broker.temps[self._evo_device.zoneId]
return self._evo_device.temperatureStatus["temperature"]
@property @property
def setpoints(self) -> Dict[str, Any]: def setpoints(self) -> Dict[str, Any]:

View File

@ -72,14 +72,13 @@ async def async_setup_platform(
return return
broker = hass.data[DOMAIN]["broker"] broker = hass.data[DOMAIN]["broker"]
loc_idx = broker.params[CONF_LOCATION_IDX]
_LOGGER.debug( _LOGGER.debug(
"Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)", "Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)",
broker.tcs.modelType, broker.tcs.modelType,
broker.tcs.systemId, broker.tcs.systemId,
broker.tcs.location.name, broker.tcs.location.name,
loc_idx, broker.params[CONF_LOCATION_IDX],
) )
# special case of RoundModulation/RoundWireless (is a single zone system) # special case of RoundModulation/RoundWireless (is a single zone system)
@ -148,9 +147,12 @@ class EvoZone(EvoChild, EvoClimateDevice):
self._name = evo_device.name self._name = evo_device.name
self._icon = "mdi:radiator" self._icon = "mdi:radiator"
self._precision = self._evo_device.setpointCapabilities["valueResolution"]
self._supported_features = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE self._supported_features = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE
self._preset_modes = list(HA_PRESET_TO_EVO) self._preset_modes = list(HA_PRESET_TO_EVO)
if evo_broker.client_v1:
self._precision = PRECISION_TENTHS
else:
self._precision = self._evo_device.setpointCapabilities["valueResolution"]
@property @property
def hvac_mode(self) -> str: def hvac_mode(self) -> str:

View File

@ -3,7 +3,7 @@
"name": "Evohome", "name": "Evohome",
"documentation": "https://www.home-assistant.io/integrations/evohome", "documentation": "https://www.home-assistant.io/integrations/evohome",
"requirements": [ "requirements": [
"evohome-async==0.3.3b4" "evohome-async==0.3.3b5"
], ],
"dependencies": [], "dependencies": [],
"codeowners": ["@zxdavb"] "codeowners": ["@zxdavb"]

View File

@ -7,7 +7,7 @@ from homeassistant.components.water_heater import (
SUPPORT_OPERATION_MODE, SUPPORT_OPERATION_MODE,
WaterHeaterDevice, WaterHeaterDevice,
) )
from homeassistant.const import PRECISION_WHOLE, STATE_OFF, STATE_ON from homeassistant.const import PRECISION_TENTHS, PRECISION_WHOLE, STATE_OFF, STATE_ON
from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.util.dt import parse_datetime from homeassistant.util.dt import parse_datetime
@ -55,7 +55,7 @@ class EvoDHW(EvoChild, WaterHeaterDevice):
self._name = "DHW controller" self._name = "DHW controller"
self._icon = "mdi:thermometer-lines" self._icon = "mdi:thermometer-lines"
self._precision = PRECISION_WHOLE self._precision = PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE
self._supported_features = SUPPORT_AWAY_MODE | SUPPORT_OPERATION_MODE self._supported_features = SUPPORT_AWAY_MODE | SUPPORT_OPERATION_MODE
@property @property

View File

@ -477,7 +477,7 @@ eternalegypt==0.0.10
# evdev==0.6.1 # evdev==0.6.1
# homeassistant.components.evohome # homeassistant.components.evohome
evohome-async==0.3.3b4 evohome-async==0.3.3b5
# homeassistant.components.dlib_face_detect # homeassistant.components.dlib_face_detect
# homeassistant.components.dlib_face_identify # homeassistant.components.dlib_face_identify