"""Support for (EMEA/EU-based) Honeywell TCC climate systems. Such systems include evohome (multi-zone), and Round Thermostat (single zone). """ from datetime import datetime, timedelta import logging from typing import Any, Dict, Optional, Tuple import aiohttp.client_exceptions import voluptuous as vol import evohomeasync2 from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS, TEMP_CELSIUS, ) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util.dt import parse_datetime, utcnow from .const import DOMAIN, STORAGE_VERSION, STORAGE_KEY, GWS, TCS _LOGGER = logging.getLogger(__name__) CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires" CONF_REFRESH_TOKEN = "refresh_token" CONF_LOCATION_IDX = "location_idx" SCAN_INTERVAL_DEFAULT = timedelta(seconds=300) SCAN_INTERVAL_MINIMUM = timedelta(seconds=60) CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_LOCATION_IDX, default=0): cv.positive_int, vol.Optional( CONF_SCAN_INTERVAL, default=SCAN_INTERVAL_DEFAULT ): vol.All(cv.time_period, vol.Range(min=SCAN_INTERVAL_MINIMUM)), } ) }, extra=vol.ALLOW_EXTRA, ) def _local_dt_to_utc(dt_naive: datetime) -> datetime: dt_aware = utcnow() + (dt_naive - datetime.now()) if dt_aware.microsecond >= 500000: dt_aware += timedelta(seconds=1) return dt_aware.replace(microsecond=0) def _utc_to_local_dt(dt_aware: datetime) -> datetime: dt_naive = datetime.now() + (dt_aware - utcnow()) if dt_naive.microsecond >= 500000: dt_naive += timedelta(seconds=1) return dt_naive.replace(microsecond=0) def _handle_exception(err) -> bool: try: raise err except evohomeasync2.AuthenticationError: _LOGGER.error( "Failed to (re)authenticate with the vendor's server. " "Check your network and the vendor's service status page. " "Check that your username and password are correct. " "Message is: %s", err, ) return False except aiohttp.ClientConnectionError: # this appears to be common with Honeywell's servers _LOGGER.warning( "Unable to connect with the vendor's server. " "Check your network and the vendor's service status page. " "Message is: %s", err, ) return False except aiohttp.ClientResponseError: if err.status == HTTP_SERVICE_UNAVAILABLE: _LOGGER.warning( "The vendor says their server is currently unavailable. " "Check the vendor's service status page." ) return False if err.status == HTTP_TOO_MANY_REQUESTS: _LOGGER.warning( "The vendor's API rate limit has been exceeded. " "If this message persists, consider increasing the %s.", CONF_SCAN_INTERVAL, ) return False raise # we don't expect/handle any other ClientResponseError 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(): return False hass.async_create_task(async_load_platform(hass, "climate", DOMAIN, {}, config)) if broker.tcs.hotwater: hass.async_create_task( async_load_platform(hass, "water_heater", DOMAIN, {}, config) ) hass.helpers.event.async_track_time_interval( broker.update, config[DOMAIN][CONF_SCAN_INTERVAL] ) return True class EvoBroker: """Container for evohome client and data.""" def __init__(self, hass, params) -> None: """Initialize the evohome client and data structure.""" self.hass = hass 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 = _utc_to_local_dt(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 self.tcs = ( client.locations[loc_idx] # pylint: disable=protected-access ._gateways[0] ._control_systems[0] ) _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 = 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: # evohomeasync2 uses naive/local datetimes access_token_expires = _local_dt_to_utc(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() store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) await store.async_save(self._app_storage) self.hass.helpers.event.async_track_point_in_utc_time( self._save_auth_tokens, access_token_expires + self.params[CONF_SCAN_INTERVAL], ) async def update(self, *args, **kwargs) -> None: """Get the latest state data of the entire evohome Location. This includes state data for the 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). """ 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: # inform the evohome devices that state data has been updated self.hass.helpers.dispatcher.async_dispatcher_send( DOMAIN, {"signal": "refresh"} ) _LOGGER.debug("Status = %s", status[GWS][0][TCS][0]) class EvoDevice(Entity): """Base for any evohome device. This includes the Controller, (up to 12) Heating Zones and (optionally) a DHW controller. """ def __init__(self, evo_broker, evo_device) -> None: """Initialize the evohome entity.""" self._evo_device = evo_device self._evo_broker = evo_broker self._evo_tcs = evo_broker.tcs self._name = self._icon = self._precision = None self._state_attributes = [] self._supported_features = None self._schedule = {} @callback def _refresh(self, packet): if packet["signal"] == "refresh": self.async_schedule_update_ha_state(force_refresh=True) @property def setpoints(self) -> Dict[str, Any]: """Return the current/next setpoints from the schedule. Only Zones & DHW controllers (but not the TCS) can have schedules. """ if not self._schedule["DailySchedules"]: return {} switchpoints = {} day_time = datetime.now() day_of_week = int(day_time.strftime("%w")) # 0 is Sunday # Iterate today's switchpoints until past the current time of day... day = self._schedule["DailySchedules"][day_of_week] sp_idx = -1 # last switchpoint of the day before for i, tmp in enumerate(day["Switchpoints"]): if day_time.strftime("%H:%M:%S") > tmp["TimeOfDay"]: sp_idx = i # current setpoint else: break # Did the current SP start yesterday? Does the next start SP tomorrow? current_sp_day = -1 if sp_idx == -1 else 0 next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0 for key, offset, idx in [ ("current", current_sp_day, sp_idx), ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)), ]: spt = switchpoints[key] = {} sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d") day = self._schedule["DailySchedules"][(day_of_week + offset) % 7] switchpoint = day["Switchpoints"][idx] dt_naive = datetime.strptime( f"{sp_date}T{switchpoint['TimeOfDay']}", "%Y-%m-%dT%H:%M:%S" ) spt["from"] = _local_dt_to_utc(dt_naive).isoformat() try: spt["temperature"] = switchpoint["heatSetpoint"] except KeyError: spt["state"] = switchpoint["DhwState"] return switchpoints @property def should_poll(self) -> bool: """Evohome entities should not be polled.""" return False @property def name(self) -> str: """Return the name of the Evohome entity.""" return self._name @property def device_state_attributes(self) -> Dict[str, Any]: """Return the Evohome-specific state attributes.""" status = {} for attr in self._state_attributes: if attr != "setpoints": status[attr] = getattr(self._evo_device, attr) if "setpoints" in self._state_attributes: status["setpoints"] = self.setpoints return {"status": status} @property def icon(self) -> str: """Return the icon to use in the frontend UI.""" return self._icon @property def supported_features(self) -> int: """Get the flag of supported features of the device.""" return self._supported_features async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" self.hass.helpers.dispatcher.async_dispatcher_connect(DOMAIN, self._refresh) @property def precision(self) -> float: """Return the temperature precision to use in the frontend UI.""" return self._precision @property def temperature_unit(self) -> str: """Return the temperature unit to use in the frontend UI.""" return TEMP_CELSIUS async def _call_client_api(self, api_function) -> None: try: await api_function except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: _handle_exception(err) self.hass.helpers.event.async_call_later( 2, self._evo_broker.update() ) # call update() in 2 seconds async def _update_schedule(self) -> None: """Get the latest state data.""" if ( not self._schedule.get("DailySchedules") or parse_datetime(self.setpoints["next"]["from"]) < utcnow() ): try: self._schedule = await self._evo_device.schedule() except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: _handle_exception(err) async def async_update(self) -> None: """Get the latest state data.""" await self._update_schedule()