Add coordinator to evohome and prune async_update code (#119432)

* functional programming tweak

* doctweak

* typing hint

* rename symbol

* Switch to DataUpdateCoordinator

* move from async_setup to EvoBroker

* tweaks - add v1 back in

* tidy up

* tidy up docstring

* lint

* remove redundant logging

* rename symbol

* split back to inject authenticator clas

* rename symbols

* rename symbol

* Update homeassistant/components/evohome/__init__.py

Co-authored-by: Joakim Plate <elupus@ecce.se>

* allow exception to pass through

* allow re-authentication with diff credentials

* lint

* undo unrelated change

* use async_refresh instead of async_config_entry_first_refresh

* assign None instead of empty dict as Falsey value

* use class attrs instead of type hints

* speed up mypy hint

* speed up mypy check

* small tidy up

* small tidy up

---------

Co-authored-by: Joakim Plate <elupus@ecce.se>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
David Bonnes 2024-07-23 15:47:53 +01:00 committed by GitHub
parent da6a7ebd42
commit 42b9c0448c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 209 additions and 149 deletions

View File

@ -14,20 +14,14 @@ import evohomeasync as ev1
from evohomeasync.schema import SZ_SESSION_ID from evohomeasync.schema import SZ_SESSION_ID
import evohomeasync2 as evo import evohomeasync2 as evo
from evohomeasync2.schema.const import ( from evohomeasync2.schema.const import (
SZ_ALLOWED_SYSTEM_MODES,
SZ_AUTO_WITH_RESET, SZ_AUTO_WITH_RESET,
SZ_CAN_BE_TEMPORARY, SZ_CAN_BE_TEMPORARY,
SZ_GATEWAY_ID,
SZ_GATEWAY_INFO,
SZ_HEAT_SETPOINT, SZ_HEAT_SETPOINT,
SZ_LOCATION_ID,
SZ_LOCATION_INFO,
SZ_SETPOINT_STATUS, SZ_SETPOINT_STATUS,
SZ_STATE_STATUS, SZ_STATE_STATUS,
SZ_SYSTEM_MODE, SZ_SYSTEM_MODE,
SZ_SYSTEM_MODE_STATUS, SZ_SYSTEM_MODE_STATUS,
SZ_TIME_UNTIL, SZ_TIME_UNTIL,
SZ_TIME_ZONE,
SZ_TIMING_MODE, SZ_TIMING_MODE,
SZ_UNTIL, SZ_UNTIL,
) )
@ -50,13 +44,14 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send, async_dispatcher_send,
) )
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.service import verify_domain_control from homeassistant.helpers.service import verify_domain_control
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from .const import ( from .const import (
ACCESS_TOKEN,
ACCESS_TOKEN_EXPIRES, ACCESS_TOKEN_EXPIRES,
ATTR_DURATION_DAYS, ATTR_DURATION_DAYS,
ATTR_DURATION_HOURS, ATTR_DURATION_HOURS,
@ -65,12 +60,11 @@ from .const import (
ATTR_ZONE_TEMP, ATTR_ZONE_TEMP,
CONF_LOCATION_IDX, CONF_LOCATION_IDX,
DOMAIN, DOMAIN,
GWS, REFRESH_TOKEN,
SCAN_INTERVAL_DEFAULT, SCAN_INTERVAL_DEFAULT,
SCAN_INTERVAL_MINIMUM, SCAN_INTERVAL_MINIMUM,
STORAGE_KEY, STORAGE_KEY,
STORAGE_VER, STORAGE_VER,
TCS,
USER_DATA, USER_DATA,
EvoService, EvoService,
) )
@ -79,6 +73,7 @@ from .helpers import (
convert_dict, convert_dict,
convert_until, convert_until,
dt_aware_to_naive, dt_aware_to_naive,
dt_local_to_aware,
handle_evo_exception, handle_evo_exception,
) )
@ -118,91 +113,158 @@ SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
) )
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class EvoSession:
"""Create a (EMEA/EU-based) Honeywell TCC system.""" """Class for evohome client instantiation & authentication."""
async def load_auth_tokens(store: Store) -> tuple[dict, dict | None]: def __init__(self, hass: HomeAssistant) -> None:
app_storage = await store.async_load() """Initialize the evohome broker and its data structure."""
tokens = dict(app_storage or {})
if tokens.pop(CONF_USERNAME, None) != config[DOMAIN][CONF_USERNAME]: self.hass = hass
# any tokens won't be valid, and store might be corrupt
await store.async_save({})
return ({}, {})
# evohomeasync2 requires naive/local datetimes as strings self._session = async_get_clientsession(hass)
if tokens.get(ACCESS_TOKEN_EXPIRES) is not None and ( self._store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY)
expires := dt_util.parse_datetime(tokens[ACCESS_TOKEN_EXPIRES])
# the main client, which uses the newer API
self.client_v2: evo.EvohomeClient | None = None
self._tokens: dict[str, Any] = {}
# the older client can be used to obtain high-precision temps (only)
self.client_v1: ev1.EvohomeClient | None = None
self.session_id: str | None = None
async def authenticate(self, username: str, password: str) -> None:
"""Check the user credentials against the web API.
Will raise evo.AuthenticationFailed if the credentials are invalid.
"""
if (
self.client_v2 is None
or username != self.client_v2.username
or password != self.client_v2.password
): ):
tokens[ACCESS_TOKEN_EXPIRES] = dt_aware_to_naive(expires) await self._load_auth_tokens(username)
user_data = tokens.pop(USER_DATA, {})
return (tokens, user_data)
store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY)
tokens, user_data = await load_auth_tokens(store)
client_v2 = evo.EvohomeClient( client_v2 = evo.EvohomeClient(
config[DOMAIN][CONF_USERNAME], username,
config[DOMAIN][CONF_PASSWORD], password,
**tokens, **self._tokens,
session=async_get_clientsession(hass), session=self._session,
) )
try: else: # force a re-authentication
client_v2 = self.client_v2
client_v2._user_account = None # noqa: SLF001
await client_v2.login() await client_v2.login()
await self.save_auth_tokens()
self.client_v2 = client_v2
self.client_v1 = ev1.EvohomeClient(
username,
password,
session_id=self.session_id,
session=self._session,
)
async def _load_auth_tokens(self, username: str) -> None:
"""Load access tokens and session_id from the store and validate them.
Sets self._tokens and self._session_id to the latest values.
"""
app_storage: dict[str, Any] = dict(await self._store.async_load() or {})
if app_storage.pop(CONF_USERNAME, None) != username:
# any tokens won't be valid, and store might be corrupt
await self._store.async_save({})
self.session_id = None
self._tokens = {}
return
# evohomeasync2 requires naive/local datetimes as strings
if app_storage.get(ACCESS_TOKEN_EXPIRES) is not None and (
expires := dt_util.parse_datetime(app_storage[ACCESS_TOKEN_EXPIRES])
):
app_storage[ACCESS_TOKEN_EXPIRES] = dt_aware_to_naive(expires)
user_data: dict[str, str] = app_storage.pop(USER_DATA, {})
self.session_id = user_data.get(SZ_SESSION_ID)
self._tokens = app_storage
async def save_auth_tokens(self) -> None:
"""Save access tokens and session_id to the store.
Sets self._tokens and self._session_id to the latest values.
"""
if self.client_v2 is None:
await self._store.async_save({})
return
# evohomeasync2 uses naive/local datetimes
access_token_expires = dt_local_to_aware(
self.client_v2.access_token_expires # type: ignore[arg-type]
)
self._tokens = {
CONF_USERNAME: self.client_v2.username,
REFRESH_TOKEN: self.client_v2.refresh_token,
ACCESS_TOKEN: self.client_v2.access_token,
ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(),
}
self.session_id = self.client_v1.broker.session_id if self.client_v1 else None
app_storage = self._tokens
if self.client_v1:
app_storage[USER_DATA] = {SZ_SESSION_ID: self.session_id}
await self._store.async_save(app_storage)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Evohome integration."""
sess = EvoSession(hass)
try:
await sess.authenticate(
config[DOMAIN][CONF_USERNAME],
config[DOMAIN][CONF_PASSWORD],
)
except evo.AuthenticationFailed as err: except evo.AuthenticationFailed as err:
handle_evo_exception(err) handle_evo_exception(err)
return False return False
finally: finally:
config[DOMAIN][CONF_PASSWORD] = "REDACTED" config[DOMAIN][CONF_PASSWORD] = "REDACTED"
assert isinstance(client_v2.installation_info, list) # mypy broker = EvoBroker(sess)
loc_idx = config[DOMAIN][CONF_LOCATION_IDX] if not broker.validate_location(
try: config[DOMAIN][CONF_LOCATION_IDX],
loc_config = client_v2.installation_info[loc_idx] ):
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 return False
if _LOGGER.isEnabledFor(logging.DEBUG): coordinator = DataUpdateCoordinator(
loc_info = { hass,
SZ_LOCATION_ID: loc_config[SZ_LOCATION_INFO][SZ_LOCATION_ID], _LOGGER,
SZ_TIME_ZONE: loc_config[SZ_LOCATION_INFO][SZ_TIME_ZONE], name=f"{DOMAIN}_coordinator",
} update_interval=config[DOMAIN][CONF_SCAN_INTERVAL],
gwy_info = { update_method=broker.async_update,
SZ_GATEWAY_ID: loc_config[GWS][0][SZ_GATEWAY_INFO][SZ_GATEWAY_ID],
TCS: loc_config[GWS][0][TCS],
}
_config = {
SZ_LOCATION_INFO: loc_info,
GWS: [{SZ_GATEWAY_INFO: gwy_info}],
}
_LOGGER.debug("Config = %s", _config)
client_v1 = ev1.EvohomeClient(
client_v2.username,
client_v2.password,
session_id=user_data.get(SZ_SESSION_ID) if user_data else None, # STORAGE_VER 1
session=async_get_clientsession(hass),
) )
hass.data[DOMAIN] = {} hass.data[DOMAIN] = {"broker": broker, "coordinator": coordinator}
hass.data[DOMAIN]["broker"] = broker = EvoBroker(
hass, client_v2, client_v1, store, config[DOMAIN]
)
await broker.save_auth_tokens() # without a listener, _schedule_refresh() won't be invoked by _async_refresh()
await broker.async_update() # get initial state coordinator.async_add_listener(lambda: None)
await coordinator.async_refresh() # get initial state
hass.async_create_task( hass.async_create_task(
async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config) async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config)
@ -212,10 +274,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config) async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config)
) )
async_track_time_interval(
hass, broker.async_update, config[DOMAIN][CONF_SCAN_INTERVAL]
)
setup_service_functions(hass, broker) setup_service_functions(hass, broker)
return True return True
@ -272,7 +330,7 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None:
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh) hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
# Enumerate which operating modes are supported by this system # Enumerate which operating modes are supported by this system
modes = broker.config[SZ_ALLOWED_SYSTEM_MODES] modes = broker.tcs.allowedSystemModes
# Not all systems support "AutoWithReset": register this handler only if required # Not all systems support "AutoWithReset": register this handler only if required
if [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_SYSTEM_MODE] == SZ_AUTO_WITH_RESET]: if [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_SYSTEM_MODE] == SZ_AUTO_WITH_RESET]:

View File

@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any
import evohomeasync2 as evo import evohomeasync2 as evo
from evohomeasync2.schema.const import ( from evohomeasync2.schema.const import (
SZ_ACTIVE_FAULTS, SZ_ACTIVE_FAULTS,
SZ_ALLOWED_SYSTEM_MODES,
SZ_SETPOINT_STATUS, SZ_SETPOINT_STATUS,
SZ_SYSTEM_ID, SZ_SYSTEM_ID,
SZ_SYSTEM_MODE, SZ_SYSTEM_MODE,
@ -44,7 +43,6 @@ from .const import (
ATTR_DURATION_UNTIL, ATTR_DURATION_UNTIL,
ATTR_SYSTEM_MODE, ATTR_SYSTEM_MODE,
ATTR_ZONE_TEMP, ATTR_ZONE_TEMP,
CONF_LOCATION_IDX,
DOMAIN, DOMAIN,
EVO_AUTO, EVO_AUTO,
EVO_AUTOECO, EVO_AUTOECO,
@ -112,8 +110,8 @@ async def async_setup_platform(
"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.loc.name,
broker.params[CONF_LOCATION_IDX], broker.loc_idx,
) )
entities: list[EvoClimateEntity] = [EvoController(broker, broker.tcs)] entities: list[EvoClimateEntity] = [EvoController(broker, broker.tcs)]
@ -367,7 +365,7 @@ class EvoController(EvoClimateEntity):
self._attr_unique_id = evo_device.systemId self._attr_unique_id = evo_device.systemId
self._attr_name = evo_device.location.name self._attr_name = evo_device.location.name
modes = [m[SZ_SYSTEM_MODE] for m in evo_broker.config[SZ_ALLOWED_SYSTEM_MODES]] modes = [m[SZ_SYSTEM_MODE] for m in evo_broker.tcs.allowedSystemModes]
self._attr_preset_modes = [ self._attr_preset_modes = [
TCS_PRESET_TO_HA[m] for m in modes if m in list(TCS_PRESET_TO_HA) TCS_PRESET_TO_HA[m] for m in modes if m in list(TCS_PRESET_TO_HA)
] ]

View File

@ -5,85 +5,93 @@ from __future__ import annotations
from collections.abc import Awaitable from collections.abc import Awaitable
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any from typing import TYPE_CHECKING, Any
import evohomeasync as ev1 import evohomeasync as ev1
from evohomeasync.schema import SZ_ID, SZ_SESSION_ID, SZ_TEMP from evohomeasync.schema import SZ_ID, SZ_TEMP
import evohomeasync2 as evo import evohomeasync2 as evo
from evohomeasync2.schema.const import (
SZ_GATEWAY_ID,
SZ_GATEWAY_INFO,
SZ_LOCATION_ID,
SZ_LOCATION_INFO,
SZ_TIME_ZONE,
)
from homeassistant.const import CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import CONF_LOCATION_IDX, DOMAIN, GWS, TCS, UTC_OFFSET
ACCESS_TOKEN, from .helpers import handle_evo_exception
ACCESS_TOKEN_EXPIRES,
CONF_LOCATION_IDX, if TYPE_CHECKING:
DOMAIN, from . import EvoSession
GWS,
REFRESH_TOKEN,
TCS,
USER_DATA,
UTC_OFFSET,
)
from .helpers import dt_local_to_aware, handle_evo_exception
_LOGGER = logging.getLogger(__name__.rpartition(".")[0]) _LOGGER = logging.getLogger(__name__.rpartition(".")[0])
class EvoBroker: class EvoBroker:
"""Container for evohome client and data.""" """Broker for evohome client broker."""
def __init__( loc_idx: int
self, loc: evo.Location
hass: HomeAssistant, loc_utc_offset: timedelta
client: evo.EvohomeClient, tcs: evo.ControlSystem
client_v1: ev1.EvohomeClient | None,
store: Store[dict[str, Any]],
params: ConfigType,
) -> 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
loc_idx = params[CONF_LOCATION_IDX] def __init__(self, sess: EvoSession) -> None:
self._location: evo.Location = client.locations[loc_idx] """Initialize the evohome broker and its data structure."""
assert isinstance(client.installation_info, list) # mypy self._sess = sess
self.hass = sess.hass
assert sess.client_v2 is not None # mypy
self.client = sess.client_v2
self.client_v1 = sess.client_v1
self.config = client.installation_info[loc_idx][GWS][0][TCS][0]
self.tcs: evo.ControlSystem = self._location._gateways[0]._control_systems[0] # noqa: SLF001
self.loc_utc_offset = timedelta(minutes=self._location.timeZone[UTC_OFFSET])
self.temps: dict[str, float | None] = {} self.temps: dict[str, float | None] = {}
async def save_auth_tokens(self) -> None: def validate_location(self, loc_idx: int) -> bool:
"""Save access tokens and session IDs to the store for later use.""" """Get the default TCS of the specified location."""
# evohomeasync2 uses naive/local datetimes
access_token_expires = dt_local_to_aware( self.loc_idx = loc_idx
self.client.access_token_expires # type: ignore[arg-type]
assert self.client.installation_info is not None # mypy
try:
loc_config = self.client.installation_info[loc_idx]
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(self.client.installation_info) - 1,
) )
return False
app_storage: dict[str, Any] = { self.loc = self.client.locations[loc_idx]
CONF_USERNAME: self.client.username, self.loc_utc_offset = timedelta(minutes=self.loc.timeZone[UTC_OFFSET])
REFRESH_TOKEN: self.client.refresh_token, self.tcs = self.loc._gateways[0]._control_systems[0] # noqa: SLF001
ACCESS_TOKEN: self.client.access_token,
ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(), if _LOGGER.isEnabledFor(logging.DEBUG):
loc_info = {
SZ_LOCATION_ID: loc_config[SZ_LOCATION_INFO][SZ_LOCATION_ID],
SZ_TIME_ZONE: loc_config[SZ_LOCATION_INFO][SZ_TIME_ZONE],
} }
gwy_info = {
SZ_GATEWAY_ID: loc_config[GWS][0][SZ_GATEWAY_INFO][SZ_GATEWAY_ID],
TCS: loc_config[GWS][0][TCS],
}
config = {
SZ_LOCATION_INFO: loc_info,
GWS: [{SZ_GATEWAY_INFO: gwy_info}],
}
_LOGGER.debug("Config = %s", config)
if self.client_v1: return True
app_storage[USER_DATA] = {
SZ_SESSION_ID: self.client_v1.broker.session_id,
} # this is the schema for STORAGE_VER == 1
else:
app_storage[USER_DATA] = {}
await self._store.async_save(app_storage)
async def call_client_api( async def call_client_api(
self, self,
@ -108,11 +116,7 @@ class EvoBroker:
assert self.client_v1 is not None # mypy check assert self.client_v1 is not None # mypy check
def get_session_id(client_v1: ev1.EvohomeClient) -> str | None: old_session_id = self._sess.session_id
user_data = client_v1.user_data if client_v1 else None
return user_data.get(SZ_SESSION_ID) if user_data else None # type: ignore[return-value]
session_id = get_session_id(self.client_v1)
try: try:
temps = await self.client_v1.get_temperatures() temps = await self.client_v1.get_temperatures()
@ -146,7 +150,7 @@ class EvoBroker:
raise raise
else: else:
if str(self.client_v1.location_id) != self._location.locationId: if str(self.client_v1.location_id) != self.loc.locationId:
_LOGGER.warning( _LOGGER.warning(
"The v2 API's configured location doesn't match " "The v2 API's configured location doesn't match "
"the v1 API's default location (there is more than one location), " "the v1 API's default location (there is more than one location), "
@ -157,8 +161,8 @@ class EvoBroker:
self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps} self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps}
finally: finally:
if self.client_v1 and session_id != self.client_v1.broker.session_id: if self.client_v1 and self.client_v1.broker.session_id != old_session_id:
await self.save_auth_tokens() await self._sess.save_auth_tokens()
_LOGGER.debug("Temperatures = %s", self.temps) _LOGGER.debug("Temperatures = %s", self.temps)
@ -168,7 +172,7 @@ class EvoBroker:
access_token = self.client.access_token # maybe receive a new token? access_token = self.client.access_token # maybe receive a new token?
try: try:
status = await self._location.refresh_status() status = await self.loc.refresh_status()
except evo.RequestFailed as err: except evo.RequestFailed as err:
handle_evo_exception(err) handle_evo_exception(err)
else: else:
@ -176,7 +180,7 @@ class EvoBroker:
_LOGGER.debug("Status = %s", status) _LOGGER.debug("Status = %s", status)
finally: finally:
if access_token != self.client.access_token: if access_token != self.client.access_token:
await self.save_auth_tokens() await self._sess.save_auth_tokens()
async def async_update(self, *args: Any) -> None: async def async_update(self, *args: Any) -> None:
"""Get the latest state data of an entire Honeywell TCC Location. """Get the latest state data of an entire Honeywell TCC Location.