mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 19:27:45 +00:00
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:
parent
5a35e52adf
commit
44b6258e48
@ -10,9 +10,9 @@ from typing import Any, Dict, Optional, Tuple
|
||||
import aiohttp.client_exceptions
|
||||
import voluptuous as vol
|
||||
import evohomeasync2
|
||||
import evohomeasync
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_PASSWORD,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_USERNAME,
|
||||
@ -32,10 +32,13 @@ from .const import DOMAIN, EVO_FOLLOW, STORAGE_VERSION, STORAGE_KEY, GWS, TCS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires"
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
||||
ACCESS_TOKEN = "access_token"
|
||||
ACCESS_TOKEN_EXPIRES = "access_token_expires"
|
||||
REFRESH_TOKEN = "refresh_token"
|
||||
USER_DATA = "user_data"
|
||||
|
||||
CONF_LOCATION_IDX = "location_idx"
|
||||
|
||||
SCAN_INTERVAL_DEFAULT = timedelta(seconds=300)
|
||||
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:
|
||||
"""Return False if the exception can't be ignored."""
|
||||
try:
|
||||
raise err
|
||||
|
||||
except evohomeasync2.AuthenticationError:
|
||||
_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 that your username and password are correct. "
|
||||
"Also check that your username and password are correct. "
|
||||
"Message is: %s",
|
||||
err,
|
||||
)
|
||||
@ -135,14 +139,77 @@ def _handle_exception(err) -> bool:
|
||||
)
|
||||
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:
|
||||
"""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
|
||||
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))
|
||||
if broker.tcs.hotwater:
|
||||
@ -160,116 +227,100 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
|
||||
class EvoBroker:
|
||||
"""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."""
|
||||
self.hass = hass
|
||||
self.client = client
|
||||
self.client_v1 = client_v1
|
||||
self._store = store
|
||||
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 = (
|
||||
client.locations[loc_idx] # pylint: disable=protected-access
|
||||
._gateways[0]
|
||||
._control_systems[0]
|
||||
)
|
||||
self.temps = None
|
||||
|
||||
_LOGGER.debug("Config = %s", self.config)
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG): # don't do an I/O unless required
|
||||
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:
|
||||
async def save_auth_tokens(self) -> None:
|
||||
"""Save access tokens and session IDs to the store for later use."""
|
||||
# evohomeasync2 uses naive/local datetimes
|
||||
access_token_expires = _local_dt_to_aware(self.client.access_token_expires)
|
||||
|
||||
self._app_storage[CONF_USERNAME] = self.params[CONF_USERNAME]
|
||||
self._app_storage[CONF_REFRESH_TOKEN] = self.client.refresh_token
|
||||
self._app_storage[CONF_ACCESS_TOKEN] = self.client.access_token
|
||||
self._app_storage[CONF_ACCESS_TOKEN_EXPIRES] = access_token_expires.isoformat()
|
||||
app_storage = {CONF_USERNAME: self.client.username}
|
||||
app_storage[REFRESH_TOKEN] = self.client.refresh_token
|
||||
app_storage[ACCESS_TOKEN] = self.client.access_token
|
||||
app_storage[ACCESS_TOKEN_EXPIRES] = access_token_expires.isoformat()
|
||||
|
||||
store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
await store.async_save(self._app_storage)
|
||||
if self.client_v1 and self.client_v1.user_data:
|
||||
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(
|
||||
self._save_auth_tokens,
|
||||
access_token_expires + self.params[CONF_SCAN_INTERVAL],
|
||||
)
|
||||
await self._store.async_save(app_storage)
|
||||
|
||||
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:
|
||||
"""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.
|
||||
Zones, DHW controller).
|
||||
"""
|
||||
loc_idx = self.params[CONF_LOCATION_IDX]
|
||||
await self._update_v2()
|
||||
|
||||
try:
|
||||
status = await self.client.locations[loc_idx].status()
|
||||
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)
|
||||
if self.client_v1:
|
||||
await self._update_v1()
|
||||
|
||||
_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):
|
||||
@ -305,10 +352,8 @@ class EvoDevice(Entity):
|
||||
self._evo_tcs = evo_broker.tcs
|
||||
|
||||
self._unique_id = self._name = self._icon = self._precision = None
|
||||
|
||||
self._device_state_attrs = {}
|
||||
self._state_attributes = []
|
||||
self._supported_features = None
|
||||
self._device_state_attrs = {}
|
||||
|
||||
@callback
|
||||
def _refresh(self) -> None:
|
||||
@ -394,9 +439,13 @@ class EvoChild(EvoDevice):
|
||||
@property
|
||||
def current_temperature(self) -> Optional[float]:
|
||||
"""Return the current temperature of a Zone."""
|
||||
if self._evo_device.temperatureStatus["isAvailable"]:
|
||||
return self._evo_device.temperatureStatus["temperature"]
|
||||
return None
|
||||
if not self._evo_device.temperatureStatus["isAvailable"]:
|
||||
return None
|
||||
|
||||
if self._evo_broker.temps:
|
||||
return self._evo_broker.temps[self._evo_device.zoneId]
|
||||
|
||||
return self._evo_device.temperatureStatus["temperature"]
|
||||
|
||||
@property
|
||||
def setpoints(self) -> Dict[str, Any]:
|
||||
|
@ -72,14 +72,13 @@ async def async_setup_platform(
|
||||
return
|
||||
|
||||
broker = hass.data[DOMAIN]["broker"]
|
||||
loc_idx = broker.params[CONF_LOCATION_IDX]
|
||||
|
||||
_LOGGER.debug(
|
||||
"Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)",
|
||||
broker.tcs.modelType,
|
||||
broker.tcs.systemId,
|
||||
broker.tcs.location.name,
|
||||
loc_idx,
|
||||
broker.params[CONF_LOCATION_IDX],
|
||||
)
|
||||
|
||||
# special case of RoundModulation/RoundWireless (is a single zone system)
|
||||
@ -148,9 +147,12 @@ class EvoZone(EvoChild, EvoClimateDevice):
|
||||
self._name = evo_device.name
|
||||
self._icon = "mdi:radiator"
|
||||
|
||||
self._precision = self._evo_device.setpointCapabilities["valueResolution"]
|
||||
self._supported_features = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE
|
||||
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
|
||||
def hvac_mode(self) -> str:
|
||||
|
@ -3,7 +3,7 @@
|
||||
"name": "Evohome",
|
||||
"documentation": "https://www.home-assistant.io/integrations/evohome",
|
||||
"requirements": [
|
||||
"evohome-async==0.3.3b4"
|
||||
"evohome-async==0.3.3b5"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": ["@zxdavb"]
|
||||
|
@ -7,7 +7,7 @@ from homeassistant.components.water_heater import (
|
||||
SUPPORT_OPERATION_MODE,
|
||||
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.util.dt import parse_datetime
|
||||
|
||||
@ -55,7 +55,7 @@ class EvoDHW(EvoChild, WaterHeaterDevice):
|
||||
self._name = "DHW controller"
|
||||
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
|
||||
|
||||
@property
|
||||
|
@ -477,7 +477,7 @@ eternalegypt==0.0.10
|
||||
# evdev==0.6.1
|
||||
|
||||
# homeassistant.components.evohome
|
||||
evohome-async==0.3.3b4
|
||||
evohome-async==0.3.3b5
|
||||
|
||||
# homeassistant.components.dlib_face_detect
|
||||
# homeassistant.components.dlib_face_identify
|
||||
|
Loading…
x
Reference in New Issue
Block a user