Skip to content

Commit

Permalink
Add coordinator to evohome and prune async_update code (#119432)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: epenet <[email protected]>
  • Loading branch information
3 people authored Jul 23, 2024
1 parent da6a7eb commit 42b9c04
Show file tree
Hide file tree
Showing 3 changed files with 213 additions and 153 deletions.
210 changes: 134 additions & 76 deletions homeassistant/components/evohome/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,14 @@
from evohomeasync.schema import SZ_SESSION_ID
import evohomeasync2 as evo
from evohomeasync2.schema.const import (
SZ_ALLOWED_SYSTEM_MODES,
SZ_AUTO_WITH_RESET,
SZ_CAN_BE_TEMPORARY,
SZ_GATEWAY_ID,
SZ_GATEWAY_INFO,
SZ_HEAT_SETPOINT,
SZ_LOCATION_ID,
SZ_LOCATION_INFO,
SZ_SETPOINT_STATUS,
SZ_STATE_STATUS,
SZ_SYSTEM_MODE,
SZ_SYSTEM_MODE_STATUS,
SZ_TIME_UNTIL,
SZ_TIME_ZONE,
SZ_TIMING_MODE,
SZ_UNTIL,
)
Expand All @@ -50,13 +44,14 @@
async_dispatcher_send,
)
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.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
import homeassistant.util.dt as dt_util

from .const import (
ACCESS_TOKEN,
ACCESS_TOKEN_EXPIRES,
ATTR_DURATION_DAYS,
ATTR_DURATION_HOURS,
Expand All @@ -65,12 +60,11 @@
ATTR_ZONE_TEMP,
CONF_LOCATION_IDX,
DOMAIN,
GWS,
REFRESH_TOKEN,
SCAN_INTERVAL_DEFAULT,
SCAN_INTERVAL_MINIMUM,
STORAGE_KEY,
STORAGE_VER,
TCS,
USER_DATA,
EvoService,
)
Expand All @@ -79,6 +73,7 @@
convert_dict,
convert_until,
dt_aware_to_naive,
dt_local_to_aware,
handle_evo_exception,
)

Expand Down Expand Up @@ -118,91 +113,158 @@
)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Create a (EMEA/EU-based) Honeywell TCC system."""
class EvoSession:
"""Class for evohome client instantiation & authentication."""

def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the evohome broker and its data structure."""

self.hass = hass

self._session = async_get_clientsession(hass)
self._store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY)

# 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.
async def load_auth_tokens(store: Store) -> tuple[dict, dict | None]:
app_storage = await store.async_load()
tokens = dict(app_storage or {})
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
):
await self._load_auth_tokens(username)

if tokens.pop(CONF_USERNAME, None) != config[DOMAIN][CONF_USERNAME]:
client_v2 = evo.EvohomeClient(
username,
password,
**self._tokens,
session=self._session,
)

else: # force a re-authentication
client_v2 = self.client_v2
client_v2._user_account = None # noqa: SLF001

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 store.async_save({})
return ({}, {})
await self._store.async_save({})

self.session_id = None
self._tokens = {}

return

# evohomeasync2 requires naive/local datetimes as strings
if tokens.get(ACCESS_TOKEN_EXPIRES) is not None and (
expires := dt_util.parse_datetime(tokens[ACCESS_TOKEN_EXPIRES])
if app_storage.get(ACCESS_TOKEN_EXPIRES) is not None and (
expires := dt_util.parse_datetime(app_storage[ACCESS_TOKEN_EXPIRES])
):
tokens[ACCESS_TOKEN_EXPIRES] = dt_aware_to_naive(expires)
app_storage[ACCESS_TOKEN_EXPIRES] = dt_aware_to_naive(expires)

user_data = tokens.pop(USER_DATA, {})
return (tokens, user_data)
user_data: dict[str, str] = app_storage.pop(USER_DATA, {})

store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY)
tokens, user_data = await load_auth_tokens(store)
self.session_id = user_data.get(SZ_SESSION_ID)
self._tokens = app_storage

client_v2 = evo.EvohomeClient(
config[DOMAIN][CONF_USERNAME],
config[DOMAIN][CONF_PASSWORD],
**tokens,
session=async_get_clientsession(hass),
)
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 client_v2.login()
await sess.authenticate(
config[DOMAIN][CONF_USERNAME],
config[DOMAIN][CONF_PASSWORD],
)

except evo.AuthenticationFailed as err:
handle_evo_exception(err)
return False

finally:
config[DOMAIN][CONF_PASSWORD] = "REDACTED"

assert isinstance(client_v2.installation_info, list) # mypy
broker = EvoBroker(sess)

loc_idx = config[DOMAIN][CONF_LOCATION_IDX]
try:
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,
)
if not broker.validate_location(
config[DOMAIN][CONF_LOCATION_IDX],
):
return False

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)

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),
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"{DOMAIN}_coordinator",
update_interval=config[DOMAIN][CONF_SCAN_INTERVAL],
update_method=broker.async_update,
)

hass.data[DOMAIN] = {}
hass.data[DOMAIN]["broker"] = broker = EvoBroker(
hass, client_v2, client_v1, store, config[DOMAIN]
)
hass.data[DOMAIN] = {"broker": broker, "coordinator": coordinator}

await broker.save_auth_tokens()
await broker.async_update() # get initial state
# without a listener, _schedule_refresh() won't be invoked by _async_refresh()
coordinator.async_add_listener(lambda: None)
await coordinator.async_refresh() # get initial state

hass.async_create_task(
async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config)
Expand All @@ -212,10 +274,6 @@ async def load_auth_tokens(store: Store) -> tuple[dict, dict | None]:
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)

return True
Expand Down Expand Up @@ -272,7 +330,7 @@ async def set_zone_override(call: ServiceCall) -> None:
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)

# 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
if [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_SYSTEM_MODE] == SZ_AUTO_WITH_RESET]:
Expand Down
8 changes: 3 additions & 5 deletions homeassistant/components/evohome/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import evohomeasync2 as evo
from evohomeasync2.schema.const import (
SZ_ACTIVE_FAULTS,
SZ_ALLOWED_SYSTEM_MODES,
SZ_SETPOINT_STATUS,
SZ_SYSTEM_ID,
SZ_SYSTEM_MODE,
Expand Down Expand Up @@ -44,7 +43,6 @@
ATTR_DURATION_UNTIL,
ATTR_SYSTEM_MODE,
ATTR_ZONE_TEMP,
CONF_LOCATION_IDX,
DOMAIN,
EVO_AUTO,
EVO_AUTOECO,
Expand Down Expand Up @@ -112,8 +110,8 @@ async def async_setup_platform(
"Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)",
broker.tcs.modelType,
broker.tcs.systemId,
broker.tcs.location.name,
broker.params[CONF_LOCATION_IDX],
broker.loc.name,
broker.loc_idx,
)

entities: list[EvoClimateEntity] = [EvoController(broker, broker.tcs)]
Expand Down Expand Up @@ -367,7 +365,7 @@ def __init__(self, evo_broker: EvoBroker, evo_device: evo.ControlSystem) -> None
self._attr_unique_id = evo_device.systemId
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 = [
TCS_PRESET_TO_HA[m] for m in modes if m in list(TCS_PRESET_TO_HA)
]
Expand Down
Loading

0 comments on commit 42b9c04

Please sign in to comment.