From 6130831a43dc22ce0df60db6bb2afdbef7004148 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 3 May 2019 00:43:19 +0100 Subject: [PATCH] Refactor evohome to prepare for water_heater (#23489) * refactor - add const.py, change order of propertys, methods * import client at top of file * remove debug line * de-lint * delint * add me as CODEOWNER * remove lint hint * delint --- CODEOWNERS | 1 + homeassistant/components/evohome/__init__.py | 151 +++++++- homeassistant/components/evohome/climate.py | 335 ++++++------------ homeassistant/components/evohome/const.py | 9 + .../components/evohome/manifest.json | 2 +- 5 files changed, 257 insertions(+), 241 deletions(-) create mode 100644 homeassistant/components/evohome/const.py diff --git a/CODEOWNERS b/CODEOWNERS index 804426ce779..fa4c29d7ef9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -73,6 +73,7 @@ homeassistant/components/epsonworkforce/* @ThaStealth homeassistant/components/eq3btsmart/* @rytilahti homeassistant/components/esphome/* @OttoWinter homeassistant/components/essent/* @TheLastProject +homeassistant/components/evohome/* @zxdavb homeassistant/components/file/* @fabaff homeassistant/components/filter/* @dgomes homeassistant/components/fitbit/* @robbiet480 diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 459a3636a06..562a32b07c6 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -5,26 +5,31 @@ # 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 +from datetime import datetime, timedelta import logging import requests.exceptions import voluptuous as vol +import evohomeclient2 + from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD, - EVENT_HOMEASSISTANT_START) + EVENT_HOMEASSISTANT_START, + HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS, + PRECISION_HALVES, TEMP_CELSIUS) 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 +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) +from homeassistant.helpers.entity import Entity + +from .const import ( + DOMAIN, DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS) _LOGGER = logging.getLogger(__name__) -DOMAIN = 'evohome' -DATA_EVOHOME = 'data_' + DOMAIN -DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN - CONF_LOCATION_IDX = 'location_idx' SCAN_INTERVAL_DEFAULT = timedelta(seconds=300) SCAN_INTERVAL_MINIMUM = timedelta(seconds=180) @@ -43,10 +48,6 @@ CONF_SECRETS = [ CONF_USERNAME, CONF_PASSWORD, ] -# These are used to help prevent E501 (line too long) violations. -GWS = 'gateways' -TCS = 'temperatureControlSystems' - # bit masks for dispatcher packets EVO_PARENT = 0x01 EVO_CHILD = 0x02 @@ -66,8 +67,6 @@ def setup(hass, hass_config): scan_interval = timedelta( minutes=(scan_interval.total_seconds() + 59) // 60) - import evohomeclient2 - try: client = evo_data['client'] = evohomeclient2.EvohomeClient( evo_data['params'][CONF_USERNAME], @@ -145,3 +144,129 @@ def setup(hass, hass_config): hass.bus.listen(EVENT_HOMEASSISTANT_START, _first_update) return True + + +class EvoDevice(Entity): + """Base for any Honeywell evohome device. + + Such devices include the Controller, (up to 12) Heating Zones and + (optionally) a DHW controller. + """ + + def __init__(self, evo_data, client, obj_ref): + """Initialize the evohome entity.""" + self._client = client + self._obj = obj_ref + + self._name = None + self._icon = None + self._type = None + + self._supported_features = None + self._operation_list = None + + self._params = evo_data['params'] + self._timers = evo_data['timers'] + self._status = {} + + self._available = False # should become True after first update() + + @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_exception(self, err): + try: + raise err + + except evohomeclient2.AuthenticationError: + _LOGGER.error( + "Failed to (re)authenticate with the vendor's server. " + "This may be a temporary error. Message is: %s", + err + ) + + except requests.exceptions.ConnectionError: + # 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 status page." + ) + + except requests.exceptions.HTTPError: + if err.response.status_code == HTTP_SERVICE_UNAVAILABLE: + _LOGGER.warning( + "Vendor says their server is currently unavailable. " + "This may be temporary; check the vendor's status page." + ) + + elif err.response.status_code == HTTP_TOO_MANY_REQUESTS: + _LOGGER.warning( + "The vendor's API rate limit has been exceeded. " + "So will cease polling, and will resume after %s seconds.", + (self._params[CONF_SCAN_INTERVAL] * 3).total_seconds() + ) + self._timers['statusUpdated'] = datetime.now() + \ + self._params[CONF_SCAN_INTERVAL] * 3 + + else: + raise # we don't expect/handle any other HTTPErrors + + # These properties, methods are from the Entity class + async def async_added_to_hass(self): + """Run when entity about to be added.""" + async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect) + + @property + def should_poll(self) -> bool: + """Most evohome devices push their state to HA. + + Only the Controller should be polled. + """ + return False + + @property + def name(self) -> str: + """Return the name to use in the frontend UI.""" + return self._name + + @property + def device_state_attributes(self): + """Return the device state attributes of the evohome device. + + 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 icon(self): + """Return the icon to use in the frontend UI.""" + return self._icon + + @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 device.""" + return self._supported_features + + # These properties are common to ClimateDevice, WaterHeaterDevice classes + @property + def precision(self): + """Return the temperature precision to use in the frontend UI.""" + return PRECISION_HALVES + + @property + def temperature_unit(self): + """Return the temperature unit to use in the frontend UI.""" + return TEMP_CELSIUS + + @property + def operation_list(self): + """Return the list of available operations.""" + return self._operation_list diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index cf6c21df10f..3e8aefe39c4 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -4,20 +4,21 @@ import logging import requests.exceptions +import evohomeclient2 + from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( STATE_AUTO, STATE_ECO, STATE_MANUAL, SUPPORT_AWAY_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) from homeassistant.const import ( - CONF_SCAN_INTERVAL, HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS, - PRECISION_HALVES, STATE_OFF, TEMP_CELSIUS) -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, dispatcher_send) + CONF_SCAN_INTERVAL, STATE_OFF,) +from homeassistant.helpers.dispatcher import dispatcher_send from . import ( - CONF_LOCATION_IDX, DATA_EVOHOME, DISPATCHER_EVOHOME, EVO_CHILD, EVO_PARENT, - GWS, TCS) + EvoDevice, + CONF_LOCATION_IDX, EVO_CHILD, EVO_PARENT) +from .const import ( + DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS) _LOGGER = logging.getLogger(__name__) @@ -103,115 +104,7 @@ async def async_setup_platform(hass, hass_config, async_add_entities, async_add_entities(entities, update_before_add=False) -class EvoClimateDevice(ClimateDevice): - """Base for a Honeywell evohome Climate device.""" - - # pylint: disable=no-member - - def __init__(self, evo_data, client, obj_ref): - """Initialize the evohome entity.""" - self._client = client - self._obj = obj_ref - - self._params = evo_data['params'] - self._timers = evo_data['timers'] - self._status = {} - - self._available = False # should become True after first update() - - 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_exception(self, err): - try: - import evohomeclient2 - raise err - - except evohomeclient2.AuthenticationError: - _LOGGER.error( - "Failed to (re)authenticate with the vendor's server. " - "This may be a temporary error. Message is: %s", - err - ) - - except requests.exceptions.ConnectionError: - # 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 status page." - ) - - except requests.exceptions.HTTPError: - if err.response.status_code == HTTP_SERVICE_UNAVAILABLE: - _LOGGER.warning( - "Vendor says their server is currently unavailable. " - "This may be temporary; check the vendor's status page." - ) - - elif err.response.status_code == HTTP_TOO_MANY_REQUESTS: - _LOGGER.warning( - "The vendor's API rate limit has been exceeded. " - "So will cease polling, and will resume after %s seconds.", - (self._params[CONF_SCAN_INTERVAL] * 3).total_seconds() - ) - self._timers['statusUpdated'] = datetime.now() + \ - self._params[CONF_SCAN_INTERVAL] * 3 - - else: - raise # we don't expect/handle any other HTTPErrors - - @property - def name(self) -> str: - """Return the name to use in the frontend UI.""" - return self._name - - @property - def icon(self): - """Return the icon to use in the frontend UI.""" - return self._icon - - @property - def device_state_attributes(self): - """Return the device state attributes of the evohome Climate device. - - 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 device.""" - return self._supported_features - - @property - def operation_list(self): - """Return the list of available operations.""" - return self._operation_list - - @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_HALVES - - -class EvoZone(EvoClimateDevice): +class EvoZone(EvoDevice, ClimateDevice): """Base for a Honeywell evohome Zone device.""" def __init__(self, evo_data, client, obj_ref): @@ -235,33 +128,6 @@ class EvoZone(EvoClimateDevice): SUPPORT_TARGET_TEMPERATURE | \ SUPPORT_ON_OFF - @property - def min_temp(self): - """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 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 target_temperature(self): - """Return the target temperature of the evohome Zone.""" - return self._status['setpointStatus']['targetHeatTemperature'] - - @property - def current_temperature(self): - """Return the current temperature of the evohome Zone.""" - return (self._status['temperatureStatus']['temperature'] - if self._status['temperatureStatus']['isAvailable'] else None) - @property def current_operation(self): """Return the current operating mode of the evohome Zone. @@ -285,6 +151,17 @@ class EvoZone(EvoClimateDevice): return current_operation + @property + def current_temperature(self): + """Return the current temperature of the evohome Zone.""" + return (self._status['temperatureStatus']['temperature'] + if self._status['temperatureStatus']['isAvailable'] else None) + + @property + def target_temperature(self): + """Return the target temperature of the evohome Zone.""" + return self._status['setpointStatus']['targetHeatTemperature'] + @property def is_on(self) -> bool: """Return True if the evohome Zone is off. @@ -297,6 +174,22 @@ class EvoZone(EvoClimateDevice): self._status['setpointStatus']['setpointMode'] == EVO_PERMOVER return not is_off + @property + def min_temp(self): + """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 temperature of a evohome Zone. + + The default is 35 (in Celsius), but it is configurable within 5-35. + """ + return self._config['setpointCapabilities']['maxHeatSetpoint'] + def _set_temperature(self, temperature, until=None): """Set the new target temperature of a Zone. @@ -305,7 +198,6 @@ class EvoZone(EvoClimateDevice): - None for PermanentOverride (i.e. indefinitely) """ try: - import evohomeclient2 self._obj.set_temperature(temperature, until) except (requests.exceptions.RequestException, evohomeclient2.AuthenticationError) as err: @@ -330,6 +222,29 @@ class EvoZone(EvoClimateDevice): """ self._set_temperature(self.min_temp, until=None) + def _set_operation_mode(self, operation_mode): + if operation_mode == EVO_FOLLOW: + try: + self._obj.cancel_temp_override() + except (requests.exceptions.RequestException, + evohomeclient2.AuthenticationError) as err: + self._handle_exception(err) + + 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 + ) + def set_operation_mode(self, operation_mode): """Set an operating mode for a Zone. @@ -354,38 +269,6 @@ class EvoZone(EvoClimateDevice): """ self._set_operation_mode(HA_STATE_TO_ZONE.get(operation_mode)) - def _set_operation_mode(self, operation_mode): - if operation_mode == EVO_FOLLOW: - try: - import evohomeclient2 - self._obj.cancel_temp_override() - except (requests.exceptions.RequestException, - evohomeclient2.AuthenticationError) as err: - self._handle_exception(err) - - 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] @@ -398,7 +281,7 @@ class EvoZone(EvoClimateDevice): self._available = True -class EvoController(EvoClimateDevice): +class EvoController(EvoDevice, ClimateDevice): """Base for a Honeywell evohome hub/Controller device. The Controller (aka TCS, temperature control system) is the parent of all @@ -445,22 +328,18 @@ class EvoController(EvoClimateDevice): return TCS_STATE_TO_HA.get(self._status['systemModeStatus']['mode']) @property - def min_temp(self): - """Return the minimum target temperature of a evohome Controller. + def current_temperature(self): + """Return the average current temperature of the Heating/DHW zones. - 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. + Although evohome Controllers do not have a target temp, one is + expected by the HA schema. """ - return 5 + tmp_list = [x for x in self._status['zones'] + if x['temperatureStatus']['isAvailable']] + temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list] - @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 + avg_temp = round(sum(temps) / len(temps), 1) if temps else None + return avg_temp @property def target_temperature(self): @@ -476,18 +355,9 @@ class EvoController(EvoClimateDevice): 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 + def is_away_mode_on(self) -> bool: + """Return True if away mode is on.""" + return self._status['systemModeStatus']['mode'] == EVO_AWAY @property def is_on(self) -> bool: @@ -499,9 +369,42 @@ class EvoController(EvoClimateDevice): return True @property - def is_away_mode_on(self) -> bool: - """Return True if away mode is on.""" - return self._status['systemModeStatus']['mode'] == EVO_AWAY + 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 maximum 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 should_poll(self) -> bool: + """Return True as the evohome Controller should always be polled.""" + return True + + def _set_operation_mode(self, operation_mode): + try: + self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access + except (requests.exceptions.RequestException, + evohomeclient2.AuthenticationError) as err: + self._handle_exception(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_TCS.get(operation_mode)) def turn_away_mode_on(self): """Turn away mode on. @@ -519,27 +422,6 @@ class EvoController(EvoClimateDevice): """ self._set_operation_mode(EVO_AUTO) - def _set_operation_mode(self, operation_mode): - try: - import evohomeclient2 - self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access - except (requests.exceptions.RequestException, - evohomeclient2.AuthenticationError) as err: - self._handle_exception(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_TCS.get(operation_mode)) - - @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 entire evohome Location. @@ -559,7 +441,6 @@ class EvoController(EvoClimateDevice): loc_idx = self._params[CONF_LOCATION_IDX] try: - import evohomeclient2 self._status.update( self._client.locations[loc_idx].status()[GWS][0][TCS][0]) except (requests.exceptions.RequestException, diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py new file mode 100644 index 00000000000..9fe1c49064f --- /dev/null +++ b/homeassistant/components/evohome/const.py @@ -0,0 +1,9 @@ +"""Provides the constants needed for evohome.""" + +DOMAIN = 'evohome' +DATA_EVOHOME = 'data_' + DOMAIN +DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN + +# These are used only to help prevent E501 (line too long) violations. +GWS = 'gateways' +TCS = 'temperatureControlSystems' diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index b612baa476a..33c1dd247b6 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "evohomeclient==0.3.2" ], "dependencies": [], - "codeowners": [] + "codeowners": ["@zxdavb"] }