Move evohome's API broker to the coordinator module (#118565)

* move Broker to coordinator module

* mypy tweak

* mypy
This commit is contained in:
David Bonnes 2024-06-06 18:02:50 +01:00 committed by GitHub
parent 62b1bde0e8
commit d40c940c20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 196 additions and 162 deletions

View File

@ -6,13 +6,12 @@ others.
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import logging import logging
from typing import Any, Final from typing import Any, Final
import evohomeasync as ev1 import evohomeasync as ev1
from evohomeasync.schema import SZ_ID, SZ_SESSION_ID, SZ_TEMP 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_ALLOWED_SYSTEM_MODES,
@ -51,14 +50,13 @@ 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_call_later, async_track_time_interval 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
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,
@ -68,21 +66,19 @@ from .const import (
CONF_LOCATION_IDX, CONF_LOCATION_IDX,
DOMAIN, DOMAIN,
GWS, GWS,
REFRESH_TOKEN,
SCAN_INTERVAL_DEFAULT, SCAN_INTERVAL_DEFAULT,
SCAN_INTERVAL_MINIMUM, SCAN_INTERVAL_MINIMUM,
STORAGE_KEY, STORAGE_KEY,
STORAGE_VER, STORAGE_VER,
TCS, TCS,
USER_DATA, USER_DATA,
UTC_OFFSET,
EvoService, EvoService,
) )
from .coordinator import EvoBroker
from .helpers import ( 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,
) )
@ -161,6 +157,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
finally: finally:
config[DOMAIN][CONF_PASSWORD] = "REDACTED" config[DOMAIN][CONF_PASSWORD] = "REDACTED"
assert isinstance(client_v2.installation_info, list) # mypy
loc_idx = config[DOMAIN][CONF_LOCATION_IDX] loc_idx = config[DOMAIN][CONF_LOCATION_IDX]
try: try:
loc_config = client_v2.installation_info[loc_idx] loc_config = client_v2.installation_info[loc_idx]
@ -342,161 +340,6 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None:
) )
class EvoBroker:
"""Container for evohome client and data."""
def __init__(
self,
hass: HomeAssistant,
client: evo.EvohomeClient,
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]
self._location: evo.Location = client.locations[loc_idx]
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] = {}
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 = dt_local_to_aware(
self.client.access_token_expires # type: ignore[arg-type]
)
app_storage: dict[str, Any] = {
CONF_USERNAME: self.client.username,
REFRESH_TOKEN: self.client.refresh_token,
ACCESS_TOKEN: self.client.access_token,
ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(),
}
if self.client_v1:
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(
self,
client_api: Awaitable[dict[str, Any] | None],
update_state: bool = True,
) -> dict[str, Any] | None:
"""Call a client API and update the broker state if required."""
try:
result = await client_api
except evo.RequestFailed as err:
handle_evo_exception(err)
return None
if update_state: # wait a moment for system to quiesce before updating state
async_call_later(self.hass, 1, self._update_v2_api_state)
return result
async def _update_v1_api_temps(self) -> None:
"""Get the latest high-precision temperatures of the default Location."""
assert self.client_v1 is not None # mypy check
def get_session_id(client_v1: ev1.EvohomeClient) -> str | None:
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:
temps = await self.client_v1.get_temperatures()
except ev1.InvalidSchema as err:
_LOGGER.warning(
(
"Unable to obtain high-precision temperatures. "
"It appears the JSON schema is not as expected, "
"so the high-precision feature will be disabled until next restart."
"Message is: %s"
),
err,
)
self.client_v1 = None
except ev1.RequestFailed as err:
_LOGGER.warning(
(
"Unable to obtain the latest high-precision temperatures. "
"Check your network and the vendor's service status page. "
"Proceeding without high-precision temperatures for now. "
"Message is: %s"
),
err,
)
self.temps = {} # high-precision temps now considered stale
except Exception:
self.temps = {} # high-precision temps now considered stale
raise
else:
if str(self.client_v1.location_id) != self._location.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 until next restart"
)
self.client_v1 = None
else:
self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps}
finally:
if self.client_v1 and session_id != self.client_v1.broker.session_id:
await self.save_auth_tokens()
_LOGGER.debug("Temperatures = %s", self.temps)
async def _update_v2_api_state(self, *args: Any) -> None:
"""Get the latest modes, temperatures, setpoints of a Location."""
access_token = self.client.access_token # maybe receive a new token?
try:
status = await self._location.refresh_status()
except evo.RequestFailed as err:
handle_evo_exception(err)
else:
async_dispatcher_send(self.hass, DOMAIN)
_LOGGER.debug("Status = %s", status)
finally:
if access_token != self.client.access_token:
await self.save_auth_tokens()
async def async_update(self, *args: Any) -> None:
"""Get the latest state data of an entire Honeywell TCC Location.
This includes state data for a Controller and all its child devices, such as the
operating mode of the Controller and the current temp of its children (e.g.
Zones, DHW controller).
"""
await self._update_v2_api_state()
if self.client_v1:
await self._update_v1_api_temps()
class EvoDevice(Entity): class EvoDevice(Entity):
"""Base for any evohome device. """Base for any evohome device.

View File

@ -0,0 +1,191 @@
"""Support for (EMEA/EU-based) Honeywell TCC systems."""
from __future__ import annotations
from collections.abc import Awaitable
from datetime import timedelta
import logging
from typing import Any
import evohomeasync as ev1
from evohomeasync.schema import SZ_ID, SZ_SESSION_ID, SZ_TEMP
import evohomeasync2 as evo
from homeassistant.const import CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from .const import (
ACCESS_TOKEN,
ACCESS_TOKEN_EXPIRES,
CONF_LOCATION_IDX,
DOMAIN,
GWS,
REFRESH_TOKEN,
TCS,
USER_DATA,
UTC_OFFSET,
)
from .helpers import dt_local_to_aware, handle_evo_exception
_LOGGER = logging.getLogger(__name__.rpartition(".")[0])
class EvoBroker:
"""Container for evohome client and data."""
def __init__(
self,
hass: HomeAssistant,
client: evo.EvohomeClient,
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]
self._location: evo.Location = client.locations[loc_idx]
assert isinstance(client.installation_info, list) # mypy
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] = {}
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 = dt_local_to_aware(
self.client.access_token_expires # type: ignore[arg-type]
)
app_storage: dict[str, Any] = {
CONF_USERNAME: self.client.username,
REFRESH_TOKEN: self.client.refresh_token,
ACCESS_TOKEN: self.client.access_token,
ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(),
}
if self.client_v1:
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(
self,
client_api: Awaitable[dict[str, Any] | None],
update_state: bool = True,
) -> dict[str, Any] | None:
"""Call a client API and update the broker state if required."""
try:
result = await client_api
except evo.RequestFailed as err:
handle_evo_exception(err)
return None
if update_state: # wait a moment for system to quiesce before updating state
async_call_later(self.hass, 1, self._update_v2_api_state)
return result
async def _update_v1_api_temps(self) -> None:
"""Get the latest high-precision temperatures of the default Location."""
assert self.client_v1 is not None # mypy check
def get_session_id(client_v1: ev1.EvohomeClient) -> str | None:
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:
temps = await self.client_v1.get_temperatures()
except ev1.InvalidSchema as err:
_LOGGER.warning(
(
"Unable to obtain high-precision temperatures. "
"It appears the JSON schema is not as expected, "
"so the high-precision feature will be disabled until next restart."
"Message is: %s"
),
err,
)
self.client_v1 = None
except ev1.RequestFailed as err:
_LOGGER.warning(
(
"Unable to obtain the latest high-precision temperatures. "
"Check your network and the vendor's service status page. "
"Proceeding without high-precision temperatures for now. "
"Message is: %s"
),
err,
)
self.temps = {} # high-precision temps now considered stale
except Exception:
self.temps = {} # high-precision temps now considered stale
raise
else:
if str(self.client_v1.location_id) != self._location.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 until next restart"
)
self.client_v1 = None
else:
self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps}
finally:
if self.client_v1 and session_id != self.client_v1.broker.session_id:
await self.save_auth_tokens()
_LOGGER.debug("Temperatures = %s", self.temps)
async def _update_v2_api_state(self, *args: Any) -> None:
"""Get the latest modes, temperatures, setpoints of a Location."""
access_token = self.client.access_token # maybe receive a new token?
try:
status = await self._location.refresh_status()
except evo.RequestFailed as err:
handle_evo_exception(err)
else:
async_dispatcher_send(self.hass, DOMAIN)
_LOGGER.debug("Status = %s", status)
finally:
if access_token != self.client.access_token:
await self.save_auth_tokens()
async def async_update(self, *args: Any) -> None:
"""Get the latest state data of an entire Honeywell TCC Location.
This includes state data for a Controller and all its child devices, such as the
operating mode of the Controller and the current temp of its children (e.g.
Zones, DHW controller).
"""
await self._update_v2_api_state()
if self.client_v1:
await self._update_v1_api_temps()