From de43237f6da0b8df159d6d44afee238e4938589d Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 12 Jul 2019 20:29:45 +0100 Subject: [PATCH] [climate] Add water_heater to evohome (#25035) * initial commit * refactor for sync * minor tweak * refactor convert code * fix regression * remove bad await * de-lint * de-lint 2 * address edge case - invalid tokens * address edge case - delint * handle no schedule * improve support for RoundThermostat * tweak logging * delint * refactor for greatness * use time_zone: for state attributes * small tweak * small tweak 2 * have datetime state attributes as UTC * have datetime state attributes as UTC - delint * have datetime state attributes as UTC - tweak * missed this - remove * de-lint type hint * use parse_datetime instead of datetime.strptime) * remove debug code * state atrribute datetimes are UTC now * revert * de-lint (again) * tweak type hints * de-lint (again, again) * tweak type hints * Convert datetime closer to sending it out --- homeassistant/components/evohome/__init__.py | 95 +++++++++++-------- homeassistant/components/evohome/climate.py | 49 +++++----- .../components/evohome/water_heater.py | 92 ++++++++++++++++++ 3 files changed, 175 insertions(+), 61 deletions(-) create mode 100644 homeassistant/components/evohome/water_heater.py diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 8b1b934fa00..1445154d267 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -2,11 +2,11 @@ Such systems include evohome (multi-zone), and Round Thermostat (single zone). """ +import asyncio from datetime import datetime, timedelta import logging -from typing import Any, Dict, Tuple +from typing import Any, Dict, Optional, Tuple -from dateutil.tz import tzlocal import requests.exceptions import voluptuous as vol import evohomeclient2 @@ -21,10 +21,10 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( - async_track_point_in_utc_time, async_track_time_interval) -from homeassistant.util.dt import as_utc, parse_datetime, utcnow + async_track_point_in_utc_time, track_time_interval) +from homeassistant.util.dt import parse_datetime, utcnow -from .const import DOMAIN, EVO_STRFTIME, STORAGE_VERSION, STORAGE_KEY, GWS, TCS +from .const import DOMAIN, STORAGE_VERSION, STORAGE_KEY, GWS, TCS _LOGGER = logging.getLogger(__name__) @@ -47,11 +47,20 @@ CONFIG_SCHEMA = vol.Schema({ def _local_dt_to_utc(dt_naive: datetime) -> datetime: - dt_aware = as_utc(dt_naive.replace(microsecond=0, tzinfo=tzlocal())) - return dt_aware.replace(tzinfo=None) + dt_aware = utcnow() + (dt_naive - datetime.now()) + if dt_aware.microsecond >= 500000: + dt_aware += timedelta(seconds=1) + return dt_aware.replace(microsecond=0) -def _handle_exception(err): +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 @@ -92,18 +101,17 @@ def _handle_exception(err): raise # we don't expect/handle any other HTTPErrors -async def async_setup(hass, hass_config): +def setup(hass, hass_config) -> bool: """Create a (EMEA/EU-based) Honeywell evohome system.""" broker = EvoBroker(hass, hass_config[DOMAIN]) - if not await broker.init_client(): + if not broker.init_client(): return False load_platform(hass, 'climate', DOMAIN, {}, hass_config) if broker.tcs.hotwater: - _LOGGER.warning("DHW controller detected, however this integration " - "does not currently support DHW controllers.") + load_platform(hass, 'water_heater', DOMAIN, {}, hass_config) - async_track_time_interval( + track_time_interval( hass, broker.update, hass_config[DOMAIN][CONF_SCAN_INTERVAL] ) @@ -126,23 +134,26 @@ class EvoBroker: hass.data[DOMAIN] = {} hass.data[DOMAIN]['broker'] = self - async def init_client(self) -> bool: + 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() + asyncio.run_coroutine_threadsafe( + self._load_auth_tokens(), self.hass.loop).result() + + # evohomeclient2 uses local datetimes + if access_token_expires is not None: + access_token_expires = _utc_to_local_dt(access_token_expires) try: - client = self.client = await self.hass.async_add_executor_job( - evohomeclient2.EvohomeClient, + client = self.client = evohomeclient2.EvohomeClient( self.params[CONF_USERNAME], self.params[CONF_PASSWORD], - False, - refresh_token, - access_token, - access_token_expires + refresh_token=refresh_token, + access_token=access_token, + access_token_expires=access_token_expires ) except (requests.exceptions.RequestException, @@ -150,13 +161,11 @@ class EvoBroker: if not _handle_exception(err): return False - else: - if access_token != self.client.access_token: - await self._save_auth_tokens() - 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] @@ -170,15 +179,19 @@ class EvoBroker: ) return False - else: - self.tcs = \ - client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa: E501; pylint: disable=protected-access + self.tcs = client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa: E501; pylint: disable=protected-access _LOGGER.debug("Config = %s", self.config) + if _LOGGER.isEnabledFor(logging.DEBUG): + # don't do an I/O unless required + _LOGGER.debug( + "Status = %s", + client.locations[loc_idx].status()[GWS][0][TCS][0]) return True - async def _load_auth_tokens(self) -> Tuple[str, str, datetime]: + 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() @@ -187,9 +200,7 @@ class EvoBroker: 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 = as_utc(parse_datetime(at_expires_str)) - at_expires_dt = at_expires_dt.astimezone(tzlocal()) - at_expires_dt = at_expires_dt.replace(tzinfo=None) + at_expires_dt = parse_datetime(at_expires_str) else: at_expires_dt = None @@ -198,14 +209,15 @@ class EvoBroker: return (None, None, None) # account switched: so tokens wont be valid async def _save_auth_tokens(self, *args) -> None: - access_token_expires_utc = _local_dt_to_utc( + # evohomeclient2 uses 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_utc.isoformat() + access_token_expires.isoformat() store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) await store.async_save(self._app_storage) @@ -213,7 +225,7 @@ class EvoBroker: async_track_point_in_utc_time( self.hass, self._save_auth_tokens, - access_token_expires_utc + access_token_expires + self.params[CONF_SCAN_INTERVAL] ) def update(self, *args, **kwargs) -> None: @@ -262,7 +274,7 @@ class EvoDevice(Entity): if packet['signal'] == 'refresh': self.async_schedule_update_ha_state(force_refresh=True) - def get_setpoints(self) -> Dict[str, Any]: + def get_setpoints(self) -> Optional[Dict[str, Any]]: """Return the current/next scheduled switchpoints. Only Zones & DHW controllers (but not the TCS) have schedules. @@ -270,6 +282,9 @@ class EvoDevice(Entity): switchpoints = {} schedule = self._evo_device.schedule() + if not schedule['DailySchedules']: + return None + day_time = datetime.now() day_of_week = int(day_time.strftime('%w')) # 0 is Sunday @@ -300,9 +315,11 @@ class EvoDevice(Entity): '{}T{}'.format(sp_date, switchpoint['TimeOfDay']), '%Y-%m-%dT%H:%M:%S') - spt['target_temp'] = switchpoint['heatSetpoint'] - spt['from_datetime'] = \ - _local_dt_to_utc(dt_naive).strftime(EVO_STRFTIME) + spt['from'] = _local_dt_to_utc(dt_naive).isoformat() + try: + spt['temperature'] = switchpoint['heatSetpoint'] + except KeyError: + spt['state'] = switchpoint['DhwState'] return switchpoints diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index efa9c3cc8fa..c9391f16045 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -11,11 +11,11 @@ from homeassistant.components.climate.const import ( HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF, PRESET_AWAY, PRESET_ECO, PRESET_HOME, SUPPORT_TARGET_TEMPERATURE, SUPPORT_PRESET_MODE) +from homeassistant.util.dt import parse_datetime from . import CONF_LOCATION_IDX, _handle_exception, EvoDevice from .const import ( - DOMAIN, EVO_STRFTIME, - EVO_RESET, EVO_AUTO, EVO_AUTOECO, EVO_AWAY, EVO_DAYOFF, EVO_CUSTOM, + DOMAIN, EVO_RESET, EVO_AUTO, EVO_AUTOECO, EVO_AWAY, EVO_DAYOFF, EVO_CUSTOM, EVO_HEATOFF, EVO_FOLLOW, EVO_TEMPOVER, EVO_PERMOVER) _LOGGER = logging.getLogger(__name__) @@ -43,8 +43,8 @@ HA_PRESET_TO_EVO = { EVO_PRESET_TO_HA = {v: k for k, v in HA_PRESET_TO_EVO.items()} -async def async_setup_platform(hass, hass_config, async_add_entities, - discovery_info=None) -> None: +def setup_platform(hass, hass_config, add_entities, + discovery_info=None) -> None: """Create the evohome Controller, and its Zones, if any.""" broker = hass.data[DOMAIN]['broker'] loc_idx = broker.params[CONF_LOCATION_IDX] @@ -60,13 +60,14 @@ async def async_setup_platform(hass, hass_config, async_add_entities, for zone_idx in broker.tcs.zones: evo_zone = broker.tcs.zones[zone_idx] _LOGGER.debug( - "Found Zone, id=%s [%s], name=%s", - evo_zone.zoneId, evo_zone.zone_type, evo_zone.name) + "Found %s, id=%s [%s], name=%s", + evo_zone.zoneType, evo_zone.zoneId, evo_zone.modelType, + evo_zone.name) zones.append(EvoZone(broker, evo_zone)) entities = [controller] + zones - async_add_entities(entities, update_before_add=True) + add_entities(entities, update_before_add=True) class EvoClimateDevice(EvoDevice, ClimateDevice): @@ -141,7 +142,7 @@ class EvoZone(EvoClimateDevice): if self._evo_device.temperatureStatus['isAvailable'] else None) @property - def target_temperature(self) -> Optional[float]: + def target_temperature(self) -> float: """Return the target temperature of the evohome Zone.""" if self._evo_tcs.systemModeStatus['mode'] == EVO_HEATOFF: return self._evo_device.setpointCapabilities['minHeatSetpoint'] @@ -172,7 +173,7 @@ class EvoZone(EvoClimateDevice): return self._evo_device.setpointCapabilities['maxHeatSetpoint'] def _set_temperature(self, temperature: float, - until: Optional[datetime] = None): + until: Optional[datetime] = None) -> None: """Set a new target temperature for the Zone. until == None means indefinitely (i.e. PermanentOverride) @@ -187,11 +188,11 @@ class EvoZone(EvoClimateDevice): """Set a new target temperature for an hour.""" until = kwargs.get('until') if until: - until = datetime.strptime(until, EVO_STRFTIME) + until = parse_datetime(until) self._set_temperature(kwargs['temperature'], until) - def _set_operation_mode(self, op_mode) -> None: + def _set_operation_mode(self, op_mode: str) -> None: """Set the Zone to one of its native EVO_* operating modes.""" if op_mode == EVO_FOLLOW: try: @@ -201,14 +202,13 @@ class EvoZone(EvoClimateDevice): _handle_exception(err) return - self._setpoints = self.get_setpoints() temperature = self._evo_device.setpointStatus['targetHeatTemperature'] + until = None # EVO_PERMOVER if op_mode == EVO_TEMPOVER: - until = self._setpoints['next']['from_datetime'] - until = datetime.strptime(until, EVO_STRFTIME) - else: # EVO_PERMOVER: - until = None + self._setpoints = self.get_setpoints() + if self._setpoints: + until = parse_datetime(self._setpoints['next']['from']) self._set_temperature(temperature, until=until) @@ -220,7 +220,7 @@ class EvoZone(EvoClimateDevice): else: # HVAC_MODE_HEAT self._set_operation_mode(EVO_FOLLOW) - def set_preset_mode(self, preset_mode: str) -> None: + def set_preset_mode(self, preset_mode: Optional[str]) -> None: """Set a new preset mode. If preset_mode is None, then revert to following the schedule. @@ -244,14 +244,19 @@ class EvoController(EvoClimateDevice): self._icon = 'mdi:thermostat' self._precision = None - self._state_attributes = [ - 'activeFaults', 'systemModeStatus'] + self._state_attributes = ['activeFaults', 'systemModeStatus'] self._supported_features = SUPPORT_PRESET_MODE self._hvac_modes = list(HA_HVAC_TO_TCS) - self._preset_modes = list(HA_PRESET_TO_TCS) self._config = dict(evo_broker.config) + + # special case of RoundThermostat + if self._config['zones'][0]['modelType'] == 'RoundModulation': + self._preset_modes = [PRESET_AWAY, PRESET_ECO] + else: + self._preset_modes = list(HA_PRESET_TO_TCS) + self._config['zones'] = '...' if 'dhw' in self._config: self._config['dhw'] = '...' @@ -307,7 +312,7 @@ class EvoController(EvoClimateDevice): for z in self._evo_device._zones] # noqa: E501; pylint: disable=protected-access return max(temps) if temps else 35 - def _set_operation_mode(self, op_mode) -> None: + def _set_operation_mode(self, op_mode: str) -> None: """Set the Controller to any of its native EVO_* operating modes.""" try: self._evo_device._set_status(op_mode) # noqa: E501; pylint: disable=protected-access @@ -319,7 +324,7 @@ class EvoController(EvoClimateDevice): """Set an operating mode for the Controller.""" self._set_operation_mode(HA_HVAC_TO_TCS.get(hvac_mode)) - def set_preset_mode(self, preset_mode: str) -> None: + def set_preset_mode(self, preset_mode: Optional[str]) -> None: """Set a new preset mode. If preset_mode is None, then revert to 'Auto' mode. diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py new file mode 100644 index 00000000000..6e851741489 --- /dev/null +++ b/homeassistant/components/evohome/water_heater.py @@ -0,0 +1,92 @@ +"""Support for WaterHeater devices of (EMEA/EU) Honeywell TCC systems.""" +import logging +from typing import List + +import requests.exceptions +import evohomeclient2 + +from homeassistant.components.water_heater import ( + SUPPORT_OPERATION_MODE, WaterHeaterDevice) +from homeassistant.const import PRECISION_WHOLE, STATE_OFF, STATE_ON +from homeassistant.util.dt import parse_datetime + +from . import _handle_exception, EvoDevice +from .const import DOMAIN, EVO_STRFTIME, EVO_FOLLOW, EVO_TEMPOVER, EVO_PERMOVER + +_LOGGER = logging.getLogger(__name__) + +HA_STATE_TO_EVO = {STATE_ON: 'On', STATE_OFF: 'Off'} +EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items()} + +HA_OPMODE_TO_DHW = {STATE_ON: EVO_FOLLOW, STATE_OFF: EVO_PERMOVER} + + +def setup_platform(hass, hass_config, add_entities, + discovery_info=None) -> None: + """Create the DHW controller.""" + broker = hass.data[DOMAIN]['broker'] + + _LOGGER.debug( + "Found DHW device, id: %s [%s]", + broker.tcs.hotwater.zoneId, broker.tcs.hotwater.zone_type) + + evo_dhw = EvoDHW(broker, broker.tcs.hotwater) + + add_entities([evo_dhw], update_before_add=True) + + +class EvoDHW(EvoDevice, WaterHeaterDevice): + """Base for a Honeywell evohome DHW controller (aka boiler).""" + + def __init__(self, evo_broker, evo_device) -> None: + """Initialize the evohome DHW controller.""" + super().__init__(evo_broker, evo_device) + + self._id = evo_device.dhwId + self._name = 'DHW controller' + self._icon = 'mdi:thermometer-lines' + + self._precision = PRECISION_WHOLE + self._state_attributes = [ + 'activeFaults', 'stateStatus', 'temperatureStatus', 'setpoints'] + + self._supported_features = SUPPORT_OPERATION_MODE + self._operation_list = list(HA_OPMODE_TO_DHW) + + self._config = evo_broker.config['dhw'] + + @property + def current_operation(self) -> str: + """Return the current operating mode (On, or Off).""" + return EVO_STATE_TO_HA[self._evo_device.stateStatus['state']] + + @property + def operation_list(self) -> List[str]: + """Return the list of available operations.""" + return self._operation_list + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + return self._evo_device.temperatureStatus['temperature'] + + def set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode for a DHW controller.""" + op_mode = HA_OPMODE_TO_DHW[operation_mode] + + state = '' if op_mode == EVO_FOLLOW else HA_STATE_TO_EVO[STATE_OFF] + until = None # EVO_FOLLOW, EVO_PERMOVER + + if op_mode == EVO_TEMPOVER: + self._setpoints = self.get_setpoints() + if self._setpoints: + until = parse_datetime(self._setpoints['next']['from']) + until = until.strftime(EVO_STRFTIME) + + data = {'Mode': op_mode, 'State': state, 'UntilTime': until} + + try: + self._evo_device._set_dhw(data) # pylint: disable=protected-access + except (requests.exceptions.RequestException, + evohomeclient2.AuthenticationError) as err: + _handle_exception(err)