diff --git a/homeassistant/components/climate/evohome.py b/homeassistant/components/climate/evohome.py index f0631228fd8..fd58e6c01e8 100644 --- a/homeassistant/components/climate/evohome.py +++ b/homeassistant/components/climate/evohome.py @@ -1,7 +1,7 @@ -"""Support for Honeywell evohome (EMEA/EU-based systems only). +"""Support for Climate devices of (EMEA/EU-based) Honeywell evohome systems. Support for a temperature control system (TCS, controller) with 0+ heating -zones (e.g. TRVs, relays) and, optionally, a DHW controller. +zones (e.g. TRVs, relays). For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.evohome/ @@ -13,29 +13,34 @@ import logging from requests.exceptions import HTTPError from homeassistant.components.climate import ( - ClimateDevice, - STATE_AUTO, - STATE_ECO, - STATE_OFF, - SUPPORT_OPERATION_MODE, + STATE_AUTO, STATE_ECO, STATE_MANUAL, STATE_OFF, SUPPORT_AWAY_MODE, + SUPPORT_ON_OFF, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + ClimateDevice ) from homeassistant.components.evohome import ( - CONF_LOCATION_IDX, - DATA_EVOHOME, - MAX_TEMP, - MIN_TEMP, - SCAN_INTERVAL_MAX + DATA_EVOHOME, DISPATCHER_EVOHOME, + CONF_LOCATION_IDX, SCAN_INTERVAL_DEFAULT, + EVO_PARENT, EVO_CHILD, + GWS, TCS, ) from homeassistant.const import ( CONF_SCAN_INTERVAL, - PRECISION_TENTHS, - TEMP_CELSIUS, HTTP_TOO_MANY_REQUESTS, + PRECISION_HALVES, + TEMP_CELSIUS ) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + dispatcher_send, + async_dispatcher_connect +) + _LOGGER = logging.getLogger(__name__) -# these are for the controller's opmode/state and the zone's state +# the Controller's opmode/state and the zone's (inherited) state EVO_RESET = 'AutoWithReset' EVO_AUTO = 'Auto' EVO_AUTOECO = 'AutoWithEco' @@ -44,7 +49,14 @@ EVO_DAYOFF = 'DayOff' EVO_CUSTOM = 'Custom' EVO_HEATOFF = 'HeatingOff' -EVO_STATE_TO_HA = { +# these are for Zones' opmode, and state +EVO_FOLLOW = 'FollowSchedule' +EVO_TEMPOVER = 'TemporaryOverride' +EVO_PERMOVER = 'PermanentOverride' + +# for the Controller. NB: evohome treats Away mode as a mode in/of itself, +# where HA considers it to 'override' the exising operating mode +TCS_STATE_TO_HA = { EVO_RESET: STATE_AUTO, EVO_AUTO: STATE_AUTO, EVO_AUTOECO: STATE_ECO, @@ -53,171 +65,150 @@ EVO_STATE_TO_HA = { EVO_CUSTOM: STATE_AUTO, EVO_HEATOFF: STATE_OFF } - -HA_STATE_TO_EVO = { +HA_STATE_TO_TCS = { STATE_AUTO: EVO_AUTO, STATE_ECO: EVO_AUTOECO, STATE_OFF: EVO_HEATOFF } +TCS_OP_LIST = list(HA_STATE_TO_TCS) -HA_OP_LIST = list(HA_STATE_TO_EVO) +# the Zones' opmode; their state is usually 'inherited' from the TCS +EVO_FOLLOW = 'FollowSchedule' +EVO_TEMPOVER = 'TemporaryOverride' +EVO_PERMOVER = 'PermanentOverride' -# 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' +# for the Zones... +ZONE_STATE_TO_HA = { + EVO_FOLLOW: STATE_AUTO, + EVO_TEMPOVER: STATE_MANUAL, + EVO_PERMOVER: STATE_MANUAL +} +HA_STATE_TO_ZONE = { + STATE_AUTO: EVO_FOLLOW, + STATE_MANUAL: EVO_PERMOVER +} +ZONE_OP_LIST = list(HA_STATE_TO_ZONE) -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. - """ +async def async_setup_platform(hass, hass_config, async_add_entities, + discovery_info=None): + """Create the evohome Controller, and its Zones, if any.""" 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 + # evohomeclient has exposed no means of accessing non-default location + # (i.e. loc_idx > 0) 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", + "setup_platform(): Found Controller, id=%s [%s], " + "name=%s (location_idx=%s)", tcs_obj_ref.systemId, + tcs_obj_ref.modelType, tcs_obj_ref.location.name, - tcs_obj_ref.modelType + loc_idx ) - parent = EvoController(evo_data, client, tcs_obj_ref) - add_entities([parent], update_before_add=True) + + controller = EvoController(evo_data, client, tcs_obj_ref) + zones = [] + + for zone_idx in tcs_obj_ref.zones: + zone_obj_ref = tcs_obj_ref.zones[zone_idx] + _LOGGER.debug( + "setup_platform(): Found Zone, id=%s [%s], " + "name=%s", + zone_obj_ref.zoneId, + zone_obj_ref.zone_type, + zone_obj_ref.name + ) + zones.append(EvoZone(evo_data, client, zone_obj_ref)) + + entities = [controller] + zones + + async_add_entities(entities, update_before_add=False) -class EvoController(ClimateDevice): - """Base for a Honeywell evohome hub/Controller device. +class EvoClimateDevice(ClimateDevice): + """Base for a Honeywell evohome Climate device.""" - The Controller (aka TCS, temperature control system) is the parent of all - the child (CH/DHW) devices. - """ + # pylint: disable=no-member 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 + """Initialize the evohome entity.""" + 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 + async def async_added_to_hass(self): + """Run when entity about to be added.""" + async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect) + @callback + def _connect(self, packet): + if packet['to'] & self._type and packet['signal'] == 'refresh': + self.async_schedule_update_ha_state(force_refresh=True) + + def _handle_requests_exceptions(self, err): 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 + # execute a backoff: pause, and also reduce rate + old_interval = self._params[CONF_SCAN_INTERVAL] + new_interval = min(old_interval, SCAN_INTERVAL_DEFAULT) * 2 + self._params[CONF_SCAN_INTERVAL] = new_interval _LOGGER.warning( - "API rate limit has been exceeded: increasing '%s' from %s to " - "%s seconds, and suspending polling for %s seconds.", + "API rate limit has been exceeded. Suspending polling for %s " + "seconds, and increasing '%s' from %s to %s seconds.", + new_interval * 3, CONF_SCAN_INTERVAL, - old_scan_interval, - new_scan_interval, - new_scan_interval * 3 + old_interval, + new_interval, ) - self._timers['statusUpdated'] = datetime.now() + \ - timedelta(seconds=new_scan_interval * 3) + self._timers['statusUpdated'] = datetime.now() + new_interval * 3 else: - raise err + raise err # we dont handle any other HTTPErrors @property - def name(self): + def name(self) -> str: """Return the name to use in the frontend UI.""" return self._name @property - def available(self): - """Return True if the device is available. + def icon(self): + """Return the icon to use in the frontend UI.""" + return self._icon - 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). + @property + def device_state_attributes(self): + """Return the device state attributes of the evohome Climate device. - However, evohome entities can become unavailable for other reasons. + This is state data that is not available otherwise, due to the + restrictions placed upon ClimateDevice properties, etc. by HA. """ + return {'status': self._status} + + @property + def available(self) -> bool: + """Return True if the device is currently available.""" 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 + """Get the list of supported features of the device.""" + return self._supported_features @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 + return self._operation_list @property def temperature_unit(self): @@ -227,47 +218,313 @@ class EvoController(ClimateDevice): @property def precision(self): """Return the temperature precision to use in the frontend UI.""" - return PRECISION_TENTHS + return PRECISION_HALVES + + +class EvoZone(EvoClimateDevice): + """Base for a Honeywell evohome Zone device.""" + + def __init__(self, evo_data, client, obj_ref): + """Initialize the evohome Zone.""" + super().__init__(evo_data, client, obj_ref) + + self._id = obj_ref.zoneId + self._name = obj_ref.name + self._icon = "mdi:radiator" + self._type = EVO_CHILD + + for _zone in evo_data['config'][GWS][0][TCS][0]['zones']: + if _zone['zoneId'] == self._id: + self._config = _zone + break + self._status = {} + + self._operation_list = ZONE_OP_LIST + self._supported_features = \ + SUPPORT_OPERATION_MODE | \ + SUPPORT_TARGET_TEMPERATURE | \ + SUPPORT_ON_OFF @property def min_temp(self): - """Return the minimum target temp (setpoint) of a evohome entity.""" - return MIN_TEMP + """Return the minimum target temperature of a evohome Zone. + + The default is 5 (in Celsius), but it is configurable within 5-35. + """ + return self._config['setpointCapabilities']['minHeatSetpoint'] @property def max_temp(self): - """Return the maximum target temp (setpoint) of a evohome entity.""" - return MAX_TEMP + """Return the minimum target temperature of a evohome Zone. + + The default is 35 (in Celsius), but it is configurable within 5-35. + """ + return self._config['setpointCapabilities']['maxHeatSetpoint'] @property - def is_on(self): - """Return true as evohome controllers are always on. + def target_temperature(self): + """Return the target temperature of the evohome Zone.""" + return self._status['setpointStatus']['targetHeatTemperature'] - Operating modes can include 'HeatingOff', but (for example) DHW would - remain on. + @property + def current_temperature(self): + """Return the current temperature of the evohome Zone.""" + return self._status['temperatureStatus']['temperature'] + + @property + def current_operation(self): + """Return the current operating mode of the evohome Zone. + + The evohome Zones that are in 'FollowSchedule' mode inherit their + actual operating mode from the Controller. + """ + evo_data = self.hass.data[DATA_EVOHOME] + + system_mode = evo_data['status']['systemModeStatus']['mode'] + setpoint_mode = self._status['setpointStatus']['setpointMode'] + + if setpoint_mode == EVO_FOLLOW: + # then inherit state from the controller + if system_mode == EVO_RESET: + current_operation = TCS_STATE_TO_HA.get(EVO_AUTO) + else: + current_operation = TCS_STATE_TO_HA.get(system_mode) + else: + current_operation = ZONE_STATE_TO_HA.get(setpoint_mode) + + return current_operation + + @property + def is_on(self) -> bool: + """Return True if the evohome Zone is off. + + A Zone is considered off if its target temp is set to its minimum, and + it is not following its schedule (i.e. not in 'FollowSchedule' mode). + """ + is_off = \ + self.target_temperature == self.min_temp and \ + self._status['setpointStatus']['setpointMode'] == EVO_PERMOVER + return not is_off + + def _set_temperature(self, temperature, until=None): + """Set the new target temperature of a Zone. + + temperature is required, until can be: + - strftime('%Y-%m-%dT%H:%M:%SZ') for TemporaryOverride, or + - None for PermanentOverride (i.e. indefinitely) + """ + try: + self._obj.set_temperature(temperature, until) + except HTTPError as err: + self._handle_exception("HTTPError", str(err)) # noqa: E501; pylint: disable=no-member + + def set_temperature(self, **kwargs): + """Set new target temperature, indefinitely.""" + self._set_temperature(kwargs['temperature'], until=None) + + def turn_on(self): + """Turn the evohome Zone on. + + This is achieved by setting the Zone to its 'FollowSchedule' mode. + """ + self._set_operation_mode(EVO_FOLLOW) + + def turn_off(self): + """Turn the evohome Zone off. + + This is achieved by setting the Zone to its minimum temperature, + indefinitely (i.e. 'PermanentOverride' mode). + """ + self._set_temperature(self.min_temp, until=None) + + def set_operation_mode(self, operation_mode): + """Set an operating mode for a Zone. + + Currently limited to 'Auto' & 'Manual'. If 'Off' is needed, it can be + enabled via turn_off method. + + NB: evohome Zones do not have an operating mode as understood by HA. + Instead they usually 'inherit' an operating mode from their controller. + + More correctly, these Zones are in a follow mode, 'FollowSchedule', + where their setpoint temperatures are a function of their schedule, and + the Controller's operating_mode, e.g. Economy mode is their scheduled + setpoint less (usually) 3C. + + Thus, you cannot set a Zone to Away mode, but the location (i.e. the + Controller) is set to Away and each Zones's setpoints are adjusted + accordingly to some lower temperature. + + However, Zones can override these setpoints, either for a specified + period of time, 'TemporaryOverride', after which they will revert back + to 'FollowSchedule' mode, or indefinitely, 'PermanentOverride'. + """ + self._set_operation_mode(HA_STATE_TO_ZONE.get(operation_mode)) + + def _set_operation_mode(self, operation_mode): + if operation_mode == EVO_FOLLOW: + try: + self._obj.cancel_temp_override(self._obj) + except HTTPError as err: + self._handle_exception("HTTPError", str(err)) # noqa: E501; pylint: disable=no-member + + elif operation_mode == EVO_TEMPOVER: + _LOGGER.error( + "_set_operation_mode(op_mode=%s): mode not yet implemented", + operation_mode + ) + + elif operation_mode == EVO_PERMOVER: + self._set_temperature(self.target_temperature, until=None) + + else: + _LOGGER.error( + "_set_operation_mode(op_mode=%s): mode not valid", + operation_mode + ) + + @property + def should_poll(self) -> bool: + """Return False as evohome child devices should never be polled. + + The evohome Controller will inform its children when to update(). + """ + return False + + def update(self): + """Process the evohome Zone's state data.""" + evo_data = self.hass.data[DATA_EVOHOME] + + for _zone in evo_data['status']['zones']: + if _zone['zoneId'] == self._id: + self._status = _zone + break + + self._available = True + + +class EvoController(EvoClimateDevice): + """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. It is also a Climate device. + """ + + def __init__(self, evo_data, client, obj_ref): + """Initialize the evohome Controller (hub).""" + super().__init__(evo_data, client, obj_ref) + + self._id = obj_ref.systemId + self._name = '_{}'.format(obj_ref.location.name) + self._icon = "mdi:thermostat" + self._type = EVO_PARENT + + self._config = evo_data['config'][GWS][0][TCS][0] + self._status = evo_data['status'] + self._timers['statusUpdated'] = datetime.min + + self._operation_list = TCS_OP_LIST + self._supported_features = \ + SUPPORT_OPERATION_MODE | \ + SUPPORT_AWAY_MODE + + @property + def device_state_attributes(self): + """Return the device state attributes of the evohome Controller. + + This is state data that is not available otherwise, due to the + restrictions placed upon ClimateDevice properties, etc. by HA. + """ + status = dict(self._status) + + if 'zones' in status: + del status['zones'] + if 'dhw' in status: + del status['dhw'] + + return {'status': status} + + @property + def current_operation(self): + """Return the current operating mode of the evohome Controller.""" + return TCS_STATE_TO_HA.get(self._status['systemModeStatus']['mode']) + + @property + def min_temp(self): + """Return the minimum target temperature of a evohome Controller. + + Although evohome Controllers do not have a minimum target temp, one is + expected by the HA schema; the default for an evohome HR92 is used. + """ + return 5 + + @property + def max_temp(self): + """Return the minimum target temperature of a evohome Controller. + + Although evohome Controllers do not have a maximum target temp, one is + expected by the HA schema; the default for an evohome HR92 is used. + """ + return 35 + + @property + def target_temperature(self): + """Return the average target temperature of the Heating/DHW zones. + + Although evohome Controllers do not have a target temp, one is + expected by the HA schema. + """ + 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. + + Although evohome Controllers do not have a target temp, one is + expected by the HA schema. + """ + 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 is_on(self) -> bool: + """Return True as evohome Controllers are always on. + + For example, evohome Controllers have a 'HeatingOff' mode, but even + then the DHW would remain on. """ return True @property - def is_away_mode_on(self): - """Return true if away mode is on.""" + def is_away_mode_on(self) -> bool: + """Return True if away mode is on.""" return self._status['systemModeStatus']['mode'] == EVO_AWAY def turn_away_mode_on(self): - """Turn away mode on.""" + """Turn away mode on. + + The evohome Controller will not remember is previous operating mode. + """ self._set_operation_mode(EVO_AWAY) def turn_away_mode_off(self): - """Turn away mode off.""" + """Turn away mode off. + + The evohome Controller can not recall its previous operating mode (as + intimated by the HA schema), so this method is achieved by setting the + Controller's mode back to Auto. + """ 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: @@ -279,93 +536,45 @@ class EvoController(ClimateDevice): 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)) + self._set_operation_mode(HA_STATE_TO_TCS.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'] - ) + @property + def should_poll(self) -> bool: + """Return True as the evohome Controller should always be polled.""" + return True def update(self): - """Get the latest state data of the installation. + """Get the latest state data of the entire evohome Location. - 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. + 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). """ - evo_data = self.hass.data[DATA_EVOHOME] - + # should the latest evohome state data be retreived this cycle? timeout = datetime.now() + timedelta(seconds=55) expired = timeout > self._timers['statusUpdated'] + \ - timedelta(seconds=evo_data['params'][CONF_SCAN_INTERVAL]) + self._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 + # Retreive the latest state data via the client api + loc_idx = self._params[CONF_LOCATION_IDX] + try: + self._status.update( + self._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: + self._timers['statusUpdated'] = datetime.now() 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 - ) + _LOGGER.debug( + "_update_state_data(): self._status = %s", + self._status + ) - 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 + "]" - ) + # inform the child devices that state data has been updated + pkt = {'sender': 'controller', 'signal': 'refresh', 'to': EVO_CHILD} + dispatcher_send(self.hass, DISPATCHER_EVOHOME, pkt) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index c445a495073..e0f104a84b1 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.7', 'somecomfort==0.5.2'] +REQUIREMENTS = ['evohomeclient==0.2.8', 'somecomfort==0.5.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/evohome.py b/homeassistant/components/evohome.py index 397d3b9f6c0..40ba5b9b70f 100644 --- a/homeassistant/components/evohome.py +++ b/homeassistant/components/evohome.py @@ -1,4 +1,4 @@ -"""Support for Honeywell evohome (EMEA/EU-based systems only). +"""Support for (EMEA/EU-based) Honeywell evohome systems. Support for a temperature control system (TCS, controller) with 0+ heating zones (e.g. TRVs, relays) and, optionally, a DHW controller. @@ -8,46 +8,48 @@ 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) +# 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) +# The TCS & Zones are implemented as Climate devices, Boiler as a WaterHeater +from datetime import timedelta 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 + CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD, + EVENT_HOMEASSISTANT_START, + HTTP_BAD_REQUEST, HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS ) - +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['evohomeclient==0.2.7'] -# If ever > 0.2.7, re-check the work-around wrapper is still required when -# instantiating the client, below. +REQUIREMENTS = ['evohomeclient==0.2.8'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'evohome' DATA_EVOHOME = 'data_' + DOMAIN +DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN CONF_LOCATION_IDX = 'location_idx' -MAX_TEMP = 28 -MIN_TEMP = 5 -SCAN_INTERVAL_DEFAULT = 180 -SCAN_INTERVAL_MAX = 300 +SCAN_INTERVAL_DEFAULT = timedelta(seconds=300) +SCAN_INTERVAL_MINIMUM = timedelta(seconds=180) 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_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) @@ -55,91 +57,107 @@ CONFIG_SCHEMA = vol.Schema({ GWS = 'gateways' TCS = 'temperatureControlSystems' +# bit masks for dispatcher packets +EVO_PARENT = 0x01 +EVO_CHILD = 0x02 -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. +def setup(hass, hass_config): + """Create a (EMEA/EU-based) Honeywell evohome system. + + Currently, only the Controller and the Zones are implemented here. """ evo_data = hass.data[DATA_EVOHOME] = {} evo_data['timers'] = {} - evo_data['params'] = dict(config[DOMAIN]) - evo_data['params'][CONF_SCAN_INTERVAL] = SCAN_INTERVAL_DEFAULT + # use a copy, since scan_interval is rounded up to nearest 60s + evo_data['params'] = dict(hass_config[DOMAIN]) + scan_interval = evo_data['params'][CONF_SCAN_INTERVAL] + scan_interval = timedelta( + minutes=(scan_interval.total_seconds() + 59) // 60) 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, " + "setup(): Failed to connect with the vendor's 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 + elif err.response.status_code == HTTP_SERVICE_UNAVAILABLE: + _LOGGER.error( + "setup(): Failed to connect with the vendor's web servers. " + "The server is not contactable. Unable to continue. " + "Resolve any errors and restart HA." + ) - finally: # Redact username, password as no longer needed. + elif err.response.status_code == HTTP_TOO_MANY_REQUESTS: + _LOGGER.error( + "setup(): Failed to connect with the vendor's web servers. " + "You have exceeded the api rate limit. Unable to continue. " + "Wait a while (say 10 minutes) and restart HA." + ) + + else: + raise # we dont expect/handle any other HTTPErrors + + return False # unable to continue + + finally: # Redact username, password as no longer needed evo_data['params'][CONF_USERNAME] = 'REDACTED' evo_data['params'][CONF_PASSWORD] = 'REDACTED' evo_data['client'] = client + evo_data['status'] = {} - # 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' + # Redact any installation data we'll never need + for loc in client.installation_info: + loc['locationInfo']['locationId'] = 'REDACTED' + loc['locationInfo']['locationOwner'] = 'REDACTED' + loc['locationInfo']['streetAddress'] = 'REDACTED' + loc['locationInfo']['city'] = 'REDACTED' + loc[GWS][0]['gatewayInfo'] = 'REDACTED' - # Pull down the installation configuration. + # 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)", + "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'] = '...' + if 'dhw' in tmp_loc[GWS][0][TCS][0]: # if this location has DHW... + tmp_loc[GWS][0][TCS][0]['dhw'] = '...' - _LOGGER.debug("setup(), location = %s", tmp_loc) + _LOGGER.debug("setup(): evo_data['config']=%s", tmp_loc) - load_platform(hass, 'climate', DOMAIN, {}, config) + load_platform(hass, 'climate', DOMAIN, {}, hass_config) + + @callback + def _first_update(event): + # When HA has started, the hub knows to retreive it's first update + pkt = {'sender': 'setup()', 'signal': 'refresh', 'to': EVO_PARENT} + async_dispatcher_send(hass, DISPATCHER_EVOHOME, pkt) + + hass.bus.listen(EVENT_HOMEASSISTANT_START, _first_update) return True diff --git a/requirements_all.txt b/requirements_all.txt index 59a29eb88b3..292cac63ee7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -358,7 +358,7 @@ eternalegypt==0.0.5 # homeassistant.components.evohome # homeassistant.components.climate.honeywell -evohomeclient==0.2.7 +evohomeclient==0.2.8 # 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 d204cfa7da9..c37429958b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -63,7 +63,7 @@ ephem==3.7.6.0 # homeassistant.components.evohome # homeassistant.components.climate.honeywell -evohomeclient==0.2.7 +evohomeclient==0.2.8 # homeassistant.components.feedreader feedparser==5.2.1