diff --git a/.coveragerc b/.coveragerc index a935564791a..5e522016096 100644 --- a/.coveragerc +++ b/.coveragerc @@ -105,6 +105,9 @@ omit = homeassistant/components/envisalink.py homeassistant/components/*/envisalink.py + homeassistant/components/evohome.py + homeassistant/components/*/evohome.py + homeassistant/components/fritzbox.py homeassistant/components/switch/fritzbox.py diff --git a/homeassistant/components/climate/evohome.py b/homeassistant/components/climate/evohome.py new file mode 100644 index 00000000000..f0631228fd8 --- /dev/null +++ b/homeassistant/components/climate/evohome.py @@ -0,0 +1,371 @@ +"""Support for Honeywell evohome (EMEA/EU-based systems only). + +Support for a temperature control system (TCS, controller) with 0+ heating +zones (e.g. TRVs, relays) and, optionally, a DHW controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.evohome/ +""" + +from datetime import datetime, timedelta +import logging + +from requests.exceptions import HTTPError + +from homeassistant.components.climate import ( + ClimateDevice, + STATE_AUTO, + STATE_ECO, + STATE_OFF, + SUPPORT_OPERATION_MODE, + SUPPORT_AWAY_MODE, +) +from homeassistant.components.evohome import ( + CONF_LOCATION_IDX, + DATA_EVOHOME, + MAX_TEMP, + MIN_TEMP, + SCAN_INTERVAL_MAX +) +from homeassistant.const import ( + CONF_SCAN_INTERVAL, + PRECISION_TENTHS, + TEMP_CELSIUS, + HTTP_TOO_MANY_REQUESTS, +) +_LOGGER = logging.getLogger(__name__) + +# these are for the controller's opmode/state and the zone's state +EVO_RESET = 'AutoWithReset' +EVO_AUTO = 'Auto' +EVO_AUTOECO = 'AutoWithEco' +EVO_AWAY = 'Away' +EVO_DAYOFF = 'DayOff' +EVO_CUSTOM = 'Custom' +EVO_HEATOFF = 'HeatingOff' + +EVO_STATE_TO_HA = { + EVO_RESET: STATE_AUTO, + EVO_AUTO: STATE_AUTO, + EVO_AUTOECO: STATE_ECO, + EVO_AWAY: STATE_AUTO, + EVO_DAYOFF: STATE_AUTO, + EVO_CUSTOM: STATE_AUTO, + EVO_HEATOFF: STATE_OFF +} + +HA_STATE_TO_EVO = { + STATE_AUTO: EVO_AUTO, + STATE_ECO: EVO_AUTOECO, + STATE_OFF: EVO_HEATOFF +} + +HA_OP_LIST = list(HA_STATE_TO_EVO) + +# these are used to help prevent E501 (line too long) violations +GWS = 'gateways' +TCS = 'temperatureControlSystems' + +# debug codes - these happen occasionally, but the cause is unknown +EVO_DEBUG_NO_RECENT_UPDATES = '0x01' +EVO_DEBUG_NO_STATUS = '0x02' + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Create a Honeywell (EMEA/EU) evohome CH/DHW system. + + An evohome system consists of: a controller, with 0-12 heating zones (e.g. + TRVs, relays) and, optionally, a DHW controller (a HW boiler). + + Here, we add the controller only. + """ + evo_data = hass.data[DATA_EVOHOME] + + client = evo_data['client'] + loc_idx = evo_data['params'][CONF_LOCATION_IDX] + + # evohomeclient has no defined way of accessing non-default location other + # than using a protected member, such as below + tcs_obj_ref = client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa E501; pylint: disable=protected-access + + _LOGGER.debug( + "setup_platform(): Found Controller: id: %s [%s], type: %s", + tcs_obj_ref.systemId, + tcs_obj_ref.location.name, + tcs_obj_ref.modelType + ) + parent = EvoController(evo_data, client, tcs_obj_ref) + add_entities([parent], update_before_add=True) + + +class EvoController(ClimateDevice): + """Base for a Honeywell evohome hub/Controller device. + + The Controller (aka TCS, temperature control system) is the parent of all + the child (CH/DHW) devices. + """ + + def __init__(self, evo_data, client, obj_ref): + """Initialize the evohome entity. + + Most read-only properties are set here. So are pseudo read-only, + for example name (which _could_ change between update()s). + """ + self.client = client + self._obj = obj_ref + + self._id = obj_ref.systemId + self._name = evo_data['config']['locationInfo']['name'] + + self._config = evo_data['config'][GWS][0][TCS][0] + self._params = evo_data['params'] + self._timers = evo_data['timers'] + + self._timers['statusUpdated'] = datetime.min + self._status = {} + + self._available = False # should become True after first update() + + def _handle_requests_exceptions(self, err): + # evohomeclient v2 api (>=0.2.7) exposes requests exceptions, incl.: + # - HTTP_BAD_REQUEST, is usually Bad user credentials + # - HTTP_TOO_MANY_REQUESTS, is api usuage limit exceeded + # - HTTP_SERVICE_UNAVAILABLE, is often Vendor's fault + + if err.response.status_code == HTTP_TOO_MANY_REQUESTS: + # execute a back off: pause, and reduce rate + old_scan_interval = self._params[CONF_SCAN_INTERVAL] + new_scan_interval = min(old_scan_interval * 2, SCAN_INTERVAL_MAX) + self._params[CONF_SCAN_INTERVAL] = new_scan_interval + + _LOGGER.warning( + "API rate limit has been exceeded: increasing '%s' from %s to " + "%s seconds, and suspending polling for %s seconds.", + CONF_SCAN_INTERVAL, + old_scan_interval, + new_scan_interval, + new_scan_interval * 3 + ) + + self._timers['statusUpdated'] = datetime.now() + \ + timedelta(seconds=new_scan_interval * 3) + + else: + raise err + + @property + def name(self): + """Return the name to use in the frontend UI.""" + return self._name + + @property + def available(self): + """Return True if the device is available. + + All evohome entities are initially unavailable. Once HA has started, + state data is then retrieved by the Controller, and then the children + will get a state (e.g. operating_mode, current_temperature). + + However, evohome entities can become unavailable for other reasons. + """ + return self._available + + @property + def supported_features(self): + """Get the list of supported features of the Controller.""" + return SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE + + @property + def device_state_attributes(self): + """Return the device state attributes of the controller. + + This is operating mode state data that is not available otherwise, due + to the restrictions placed upon ClimateDevice properties, etc by HA. + """ + data = {} + data['systemMode'] = self._status['systemModeStatus']['mode'] + data['isPermanent'] = self._status['systemModeStatus']['isPermanent'] + if 'timeUntil' in self._status['systemModeStatus']: + data['timeUntil'] = self._status['systemModeStatus']['timeUntil'] + data['activeFaults'] = self._status['activeFaults'] + return data + + @property + def operation_list(self): + """Return the list of available operations.""" + return HA_OP_LIST + + @property + def current_operation(self): + """Return the operation mode of the evohome entity.""" + return EVO_STATE_TO_HA.get(self._status['systemModeStatus']['mode']) + + @property + def target_temperature(self): + """Return the average target temperature of the Heating/DHW zones.""" + temps = [zone['setpointStatus']['targetHeatTemperature'] + for zone in self._status['zones']] + + avg_temp = round(sum(temps) / len(temps), 1) if temps else None + return avg_temp + + @property + def current_temperature(self): + """Return the average current temperature of the Heating/DHW zones.""" + tmp_list = [x for x in self._status['zones'] + if x['temperatureStatus']['isAvailable'] is True] + temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list] + + avg_temp = round(sum(temps) / len(temps), 1) if temps else None + return avg_temp + + @property + def temperature_unit(self): + """Return the temperature unit to use in the frontend UI.""" + return TEMP_CELSIUS + + @property + def precision(self): + """Return the temperature precision to use in the frontend UI.""" + return PRECISION_TENTHS + + @property + def min_temp(self): + """Return the minimum target temp (setpoint) of a evohome entity.""" + return MIN_TEMP + + @property + def max_temp(self): + """Return the maximum target temp (setpoint) of a evohome entity.""" + return MAX_TEMP + + @property + def is_on(self): + """Return true as evohome controllers are always on. + + Operating modes can include 'HeatingOff', but (for example) DHW would + remain on. + """ + return True + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._status['systemModeStatus']['mode'] == EVO_AWAY + + def turn_away_mode_on(self): + """Turn away mode on.""" + self._set_operation_mode(EVO_AWAY) + + def turn_away_mode_off(self): + """Turn away mode off.""" + self._set_operation_mode(EVO_AUTO) + + def _set_operation_mode(self, operation_mode): + # Set new target operation mode for the TCS. + _LOGGER.debug( + "_set_operation_mode(): API call [1 request(s)]: " + "tcs._set_status(%s)...", + operation_mode + ) + try: + self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access + except HTTPError as err: + self._handle_requests_exceptions(err) + + def set_operation_mode(self, operation_mode): + """Set new target operation mode for the TCS. + + Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away' + mode is needed, it can be enabled via turn_away_mode_on method. + """ + self._set_operation_mode(HA_STATE_TO_EVO.get(operation_mode)) + + def _update_state_data(self, evo_data): + client = evo_data['client'] + loc_idx = evo_data['params'][CONF_LOCATION_IDX] + + _LOGGER.debug( + "_update_state_data(): API call [1 request(s)]: " + "client.locations[loc_idx].status()..." + ) + + try: + evo_data['status'].update( + client.locations[loc_idx].status()[GWS][0][TCS][0]) + except HTTPError as err: # check if we've exceeded the api rate limit + self._handle_requests_exceptions(err) + else: + evo_data['timers']['statusUpdated'] = datetime.now() + + _LOGGER.debug( + "_update_state_data(): evo_data['status'] = %s", + evo_data['status'] + ) + + def update(self): + """Get the latest state data of the installation. + + This includes state data for the Controller and its child devices, such + as the operating_mode of the Controller and the current_temperature + of its children. + + This is not asyncio-friendly due to the underlying client api. + """ + evo_data = self.hass.data[DATA_EVOHOME] + + timeout = datetime.now() + timedelta(seconds=55) + expired = timeout > self._timers['statusUpdated'] + \ + timedelta(seconds=evo_data['params'][CONF_SCAN_INTERVAL]) + + if not expired: + return + + was_available = self._available or \ + self._timers['statusUpdated'] == datetime.min + + self._update_state_data(evo_data) + self._status = evo_data['status'] + + if _LOGGER.isEnabledFor(logging.DEBUG): + tmp_dict = dict(self._status) + if 'zones' in tmp_dict: + tmp_dict['zones'] = '...' + if 'dhw' in tmp_dict: + tmp_dict['dhw'] = '...' + + _LOGGER.debug( + "update(%s), self._status = %s", + self._id + " [" + self._name + "]", + tmp_dict + ) + + no_recent_updates = self._timers['statusUpdated'] < datetime.now() - \ + timedelta(seconds=self._params[CONF_SCAN_INTERVAL] * 3.1) + + if no_recent_updates: + self._available = False + debug_code = EVO_DEBUG_NO_RECENT_UPDATES + + elif not self._status: + # unavailable because no status (but how? other than at startup?) + self._available = False + debug_code = EVO_DEBUG_NO_STATUS + + else: + self._available = True + + if not self._available and was_available: + # only warn if available went from True to False + _LOGGER.warning( + "The entity, %s, has become unavailable, debug code is: %s", + self._id + " [" + self._name + "]", + debug_code + ) + + elif self._available and not was_available: + # this isn't the first re-available (e.g. _after_ STARTUP) + _LOGGER.debug( + "The entity, %s, has become available", + self._id + " [" + self._name + "]" + ) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 6d54695fa7a..c445a495073 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE, CONF_REGION) -REQUIREMENTS = ['evohomeclient==0.2.5', 'somecomfort==0.5.2'] +REQUIREMENTS = ['evohomeclient==0.2.7', 'somecomfort==0.5.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/evohome.py b/homeassistant/components/evohome.py new file mode 100644 index 00000000000..ceeec407b05 --- /dev/null +++ b/homeassistant/components/evohome.py @@ -0,0 +1,145 @@ +"""Support for Honeywell evohome (EMEA/EU-based systems only). + +Support for a temperature control system (TCS, controller) with 0+ heating +zones (e.g. TRVs, relays) and, optionally, a DHW controller. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/evohome/ +""" + +# Glossary: +# TCS - temperature control system (a.k.a. Controller, Parent), which can +# have up to 13 Children: +# 0-12 Heating zones (a.k.a. Zone), and +# 0-1 DHW controller, (a.k.a. Boiler) + +import logging + +from requests.exceptions import HTTPError +import voluptuous as vol + +from homeassistant.const import ( + CONF_USERNAME, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + HTTP_BAD_REQUEST +) + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform + +REQUIREMENTS = ['evohomeclient==0.2.7'] +# If ever > 0.2.7, re-check the work-around wrapper is still required when +# instantiating the client, below. + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'evohome' +DATA_EVOHOME = 'data_' + DOMAIN + +CONF_LOCATION_IDX = 'location_idx' +MAX_TEMP = 28 +MIN_TEMP = 5 +SCAN_INTERVAL_DEFAULT = 180 +SCAN_INTERVAL_MAX = 300 + +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, + }), +}, extra=vol.ALLOW_EXTRA) + +# These are used to help prevent E501 (line too long) violations. +GWS = 'gateways' +TCS = 'temperatureControlSystems' + + +def setup(hass, config): + """Create a Honeywell (EMEA/EU) evohome CH/DHW system. + + One controller with 0+ heating zones (e.g. TRVs, relays) and, optionally, a + DHW controller. Does not work for US-based systems. + """ + evo_data = hass.data[DATA_EVOHOME] = {} + evo_data['timers'] = {} + + evo_data['params'] = dict(config[DOMAIN]) + evo_data['params'][CONF_SCAN_INTERVAL] = SCAN_INTERVAL_DEFAULT + + from evohomeclient2 import EvohomeClient + + _LOGGER.debug("setup(): API call [4 request(s)]: client.__init__()...") + + try: + # There's a bug in evohomeclient2 v0.2.7: the client.__init__() sets + # the root loglevel when EvohomeClient(debug=?), so remember it now... + log_level = logging.getLogger().getEffectiveLevel() + + client = EvohomeClient( + evo_data['params'][CONF_USERNAME], + evo_data['params'][CONF_PASSWORD], + debug=False + ) + # ...then restore it to what it was before instantiating the client + logging.getLogger().setLevel(log_level) + + except HTTPError as err: + if err.response.status_code == HTTP_BAD_REQUEST: + _LOGGER.error( + "Failed to establish a connection with evohome web servers, " + "Check your username (%s), and password are correct." + "Unable to continue. Resolve any errors and restart HA.", + evo_data['params'][CONF_USERNAME] + ) + return False # unable to continue + + raise # we dont handle any other HTTPErrors + + finally: # Redact username, password as no longer needed. + evo_data['params'][CONF_USERNAME] = 'REDACTED' + evo_data['params'][CONF_PASSWORD] = 'REDACTED' + + evo_data['client'] = client + + # Redact any installation data we'll never need. + if client.installation_info[0]['locationInfo']['locationId'] != 'REDACTED': + for loc in client.installation_info: + loc['locationInfo']['streetAddress'] = 'REDACTED' + loc['locationInfo']['city'] = 'REDACTED' + loc['locationInfo']['locationOwner'] = 'REDACTED' + loc[GWS][0]['gatewayInfo'] = 'REDACTED' + + # Pull down the installation configuration. + loc_idx = evo_data['params'][CONF_LOCATION_IDX] + + try: + evo_data['config'] = client.installation_info[loc_idx] + + except IndexError: + _LOGGER.warning( + "setup(): Parameter '%s' = %s , is outside its range (0-%s)", + CONF_LOCATION_IDX, + loc_idx, + len(client.installation_info) - 1 + ) + + return False # unable to continue + + evo_data['status'] = {} + + if _LOGGER.isEnabledFor(logging.DEBUG): + tmp_loc = dict(evo_data['config']) + tmp_loc['locationInfo']['postcode'] = 'REDACTED' + tmp_tcs = tmp_loc[GWS][0][TCS][0] + if 'zones' in tmp_tcs: + tmp_tcs['zones'] = '...' + if 'dhw' in tmp_tcs: + tmp_tcs['dhw'] = '...' + + _LOGGER.debug("setup(), location = %s", tmp_loc) + + load_platform(hass, 'climate', DOMAIN) + + return True diff --git a/homeassistant/const.py b/homeassistant/const.py index 0bcfcd9d4ad..1d3adf7ee45 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -410,7 +410,9 @@ HTTP_UNAUTHORIZED = 401 HTTP_NOT_FOUND = 404 HTTP_METHOD_NOT_ALLOWED = 405 HTTP_UNPROCESSABLE_ENTITY = 422 +HTTP_TOO_MANY_REQUESTS = 429 HTTP_INTERNAL_SERVER_ERROR = 500 +HTTP_SERVICE_UNAVAILABLE = 503 HTTP_BASIC_AUTHENTICATION = 'basic' HTTP_DIGEST_AUTHENTICATION = 'digest' diff --git a/requirements_all.txt b/requirements_all.txt index a954b2cf94f..6efd9a21d7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -338,8 +338,9 @@ eternalegypt==0.0.5 # homeassistant.components.keyboard_remote # evdev==0.6.1 +# homeassistant.components.evohome # homeassistant.components.climate.honeywell -evohomeclient==0.2.5 +evohomeclient==0.2.7 # homeassistant.components.image_processing.dlib_face_detect # homeassistant.components.image_processing.dlib_face_identify diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f33b37e01d1..1f7d58ef6aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -52,8 +52,9 @@ dsmr_parser==0.11 # homeassistant.components.sensor.season ephem==3.7.6.0 +# homeassistant.components.evohome # homeassistant.components.climate.honeywell -evohomeclient==0.2.5 +evohomeclient==0.2.7 # homeassistant.components.feedreader # homeassistant.components.sensor.geo_rss_events