243 lines
8.0 KiB
Python
243 lines
8.0 KiB
Python
"""Support for (EMEA/EU-based) Honeywell TCC systems."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Awaitable
|
|
from datetime import timedelta
|
|
from http import HTTPStatus
|
|
import logging
|
|
from typing import Any
|
|
|
|
import evohomeasync as ec1
|
|
import evohomeasync2 as ec2
|
|
from evohomeasync2.const import (
|
|
SZ_DHW,
|
|
SZ_GATEWAY_ID,
|
|
SZ_GATEWAY_INFO,
|
|
SZ_GATEWAYS,
|
|
SZ_LOCATION_ID,
|
|
SZ_LOCATION_INFO,
|
|
SZ_TEMPERATURE_CONTROL_SYSTEMS,
|
|
SZ_TIME_ZONE,
|
|
SZ_USE_DAYLIGHT_SAVE_SWITCHING,
|
|
SZ_ZONES,
|
|
)
|
|
from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT
|
|
|
|
from homeassistant.const import CONF_SCAN_INTERVAL
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
|
|
|
|
class EvoDataUpdateCoordinator(DataUpdateCoordinator):
|
|
"""Coordinator for evohome integration/client."""
|
|
|
|
# These will not be None after _async_setup())
|
|
loc: ec2.Location
|
|
tcs: ec2.ControlSystem
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
logger: logging.Logger,
|
|
client_v2: ec2.EvohomeClient,
|
|
*,
|
|
name: str,
|
|
update_interval: timedelta,
|
|
location_idx: int,
|
|
client_v1: ec1.EvohomeClient | None = None,
|
|
) -> None:
|
|
"""Class to manage fetching data."""
|
|
|
|
super().__init__(
|
|
hass,
|
|
logger,
|
|
config_entry=None,
|
|
name=name,
|
|
update_interval=update_interval,
|
|
)
|
|
|
|
self.client = client_v2
|
|
self.client_v1 = client_v1
|
|
|
|
self.loc_idx = location_idx
|
|
|
|
self.data: EvoLocStatusResponseT = None # type: ignore[assignment]
|
|
self.temps: dict[str, float | None] = {}
|
|
|
|
self._first_refresh_done = False # get schedules only after first refresh
|
|
|
|
# our version of async_config_entry_first_refresh()...
|
|
async def async_first_refresh(self) -> None:
|
|
"""Refresh data for the first time when integration is setup.
|
|
|
|
This integration does not have config flow, so it is inappropriate to
|
|
invoke `async_config_entry_first_refresh()`.
|
|
"""
|
|
|
|
# can't replicate `if not await self.__wrap_async_setup():` (is mangled), so...
|
|
if not await self._DataUpdateCoordinator__wrap_async_setup(): # type: ignore[attr-defined]
|
|
return
|
|
|
|
await self._async_refresh(
|
|
log_failures=False, raise_on_auth_failed=True, raise_on_entry_error=True
|
|
)
|
|
|
|
async def _async_setup(self) -> None:
|
|
"""Set up the coordinator.
|
|
|
|
Fetch the user information, and the configuration of their locations.
|
|
"""
|
|
|
|
try:
|
|
await self.client.update(dont_update_status=True) # only config for now
|
|
except ec2.EvohomeError as err:
|
|
raise UpdateFailed(err) from err
|
|
|
|
try:
|
|
self.loc = self.client.locations[self.loc_idx]
|
|
except IndexError as err:
|
|
raise UpdateFailed(
|
|
f"""
|
|
Config error: 'location_idx' = {self.loc_idx},
|
|
but the valid range is 0-{len(self.client.locations) - 1}.
|
|
Unable to continue. Fix any configuration errors and restart HA
|
|
"""
|
|
) from err
|
|
|
|
self.tcs = self.loc.gateways[0].systems[0]
|
|
|
|
if self.logger.isEnabledFor(logging.DEBUG):
|
|
loc_info = {
|
|
SZ_LOCATION_ID: self.loc.id,
|
|
SZ_TIME_ZONE: self.loc.config[SZ_TIME_ZONE],
|
|
SZ_USE_DAYLIGHT_SAVE_SWITCHING: self.loc.config[
|
|
SZ_USE_DAYLIGHT_SAVE_SWITCHING
|
|
],
|
|
}
|
|
tcs_info: EvoTcsConfigResponseT = self.tcs.config # type: ignore[assignment]
|
|
tcs_info[SZ_ZONES] = [zone.config for zone in self.tcs.zones]
|
|
if self.tcs.hotwater:
|
|
tcs_info[SZ_DHW] = self.tcs.hotwater.config
|
|
gwy_info = {
|
|
SZ_GATEWAY_ID: self.loc.gateways[0].id,
|
|
SZ_TEMPERATURE_CONTROL_SYSTEMS: [tcs_info],
|
|
}
|
|
config = {
|
|
SZ_LOCATION_INFO: loc_info,
|
|
SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}],
|
|
}
|
|
self.logger.debug("Config = %s", [config])
|
|
|
|
async def call_client_api(
|
|
self,
|
|
client_api: Awaitable[dict[str, Any] | None],
|
|
request_refresh: bool = True,
|
|
) -> dict[str, Any] | None:
|
|
"""Call a client API and update the Coordinator state if required."""
|
|
|
|
try:
|
|
result = await client_api
|
|
|
|
except ec2.ApiRequestFailedError as err:
|
|
self.logger.error(err)
|
|
return None
|
|
|
|
if request_refresh: # wait a moment for system to quiesce before updating state
|
|
await self.async_request_refresh() # hass.async_create_task() won't help
|
|
|
|
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
|
|
|
|
try:
|
|
await self.client_v1.update()
|
|
|
|
except ec1.BadUserCredentialsError as err:
|
|
self.logger.warning(
|
|
(
|
|
"Unable to obtain high-precision temperatures. "
|
|
"The feature will be disabled until next restart: %r"
|
|
),
|
|
err,
|
|
)
|
|
self.client_v1 = None
|
|
|
|
except ec1.EvohomeError as err:
|
|
self.logger.warning(
|
|
(
|
|
"Unable to obtain the latest high-precision temperatures. "
|
|
"They will be ignored this refresh cycle: %r"
|
|
),
|
|
err,
|
|
)
|
|
self.temps = {} # high-precision temps now considered stale
|
|
|
|
else:
|
|
self.temps = await self.client_v1.location_by_id[
|
|
self.loc.id
|
|
].get_temperatures(dont_update_status=True)
|
|
|
|
self.logger.debug("Status (high-res temps) = %s", self.temps)
|
|
|
|
async def _update_v2_api_state(self, *args: Any) -> None:
|
|
"""Get the latest modes, temperatures, setpoints of a Location."""
|
|
|
|
try:
|
|
status = await self.loc.update()
|
|
|
|
except ec2.ApiRequestFailedError as err:
|
|
if err.status != HTTPStatus.TOO_MANY_REQUESTS:
|
|
raise UpdateFailed(err) from err
|
|
|
|
raise UpdateFailed(
|
|
f"""
|
|
The vendor's API rate limit has been exceeded.
|
|
Consider increasing the {CONF_SCAN_INTERVAL}
|
|
"""
|
|
) from err
|
|
|
|
except ec2.EvohomeError as err:
|
|
raise UpdateFailed(err) from err
|
|
|
|
self.logger.debug("Status = %s", status)
|
|
|
|
async def _update_v2_schedules(self) -> None:
|
|
for zone in self.tcs.zones:
|
|
try:
|
|
await zone.get_schedule()
|
|
except ec2.InvalidScheduleError as err:
|
|
self.logger.warning(
|
|
"Zone '%s' has an invalid/missing schedule: %r", zone.name, err
|
|
)
|
|
|
|
if dhw := self.tcs.hotwater:
|
|
try:
|
|
await dhw.get_schedule()
|
|
except ec2.InvalidScheduleError as err:
|
|
self.logger.warning("DHW has an invalid/missing schedule: %r", err)
|
|
|
|
async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override]
|
|
"""Fetch the latest state of an entire 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() # may raise UpdateFailed
|
|
if self.client_v1:
|
|
await self._update_v1_api_temps() # will never raise UpdateFailed
|
|
|
|
# to speed up HA startup, don't update entity schedules during initial
|
|
# async_first_refresh(), only during subsequent async_refresh()...
|
|
if self._first_refresh_done:
|
|
await self._update_v2_schedules()
|
|
else:
|
|
self._first_refresh_done = True
|
|
|
|
return self.loc.status
|