Improve evohome exception handling and fix bugs (#22140)

* Use latest client library, evohomeclient v0.3.1

* Fix issue #22097: Failed to call service climate/turn_on...

* BUGFIX: handle case where a Zone doesn't have a temperature

* BUGFIX: missing exception handler, and inappropriate delint hints

* Improve exception handling, and also better messages

* improve code (REDACT secrets); remove TODOs

* minor refactor - improve error message

* more refactoring - improve error message

* remove TODOs

* update to latest evohomeclient library

* Use latest client library, evohomeclient v0.3.1

* Fix issue #22097: Failed to call service climate/turn_on...

* BUGFIX: handle case where a Zone doesn't have a temperature

* BUGFIX: missing exception handler, and inappropriate delint hints

* Improve exception handling, and also better messages

* improve code (REDACT secrets); remove TODOs

* minor refactor - improve error message

* more refactoring - improve error message

* remove TODOs

* update to latest evohomeclient library

* fix requests for houndci-bot

* Tidy up requests exception handling

* Correct lint error

* update to latest client library

* minor de-lint

* more cleanup of exceptions, messages

* refactored for new exception

* fix error in requirements*_all.txt

* de-lint

* delint unused import

* import 3rd-party library only inside methods

* change honeywell tests

* delint, fix typo

* we dont log usernames, passwords, etc.

* de-lint
pull/22660/head
David Bonnes 2019-04-02 14:11:26 +01:00 committed by Martin Hjelmare
parent 16e0953f26
commit 3bd37d6a65
6 changed files with 107 additions and 78 deletions

View File

@ -8,20 +8,18 @@
from datetime import timedelta from datetime import timedelta
import logging import logging
from requests.exceptions import HTTPError import requests.exceptions
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ( from homeassistant.const import (
CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD,
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_START)
HTTP_BAD_REQUEST, HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS
)
from homeassistant.core import callback from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
REQUIREMENTS = ['evohomeclient==0.2.8'] REQUIREMENTS = ['evohomeclient==0.3.2']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -43,6 +41,10 @@ CONFIG_SCHEMA = vol.Schema({
}), }),
}, extra=vol.ALLOW_EXTRA) }, extra=vol.ALLOW_EXTRA)
CONF_SECRETS = [
CONF_USERNAME, CONF_PASSWORD,
]
# These are used to help prevent E501 (line too long) violations. # These are used to help prevent E501 (line too long) violations.
GWS = 'gateways' GWS = 'gateways'
TCS = 'temperatureControlSystems' TCS = 'temperatureControlSystems'
@ -66,51 +68,40 @@ def setup(hass, hass_config):
scan_interval = timedelta( scan_interval = timedelta(
minutes=(scan_interval.total_seconds() + 59) // 60) minutes=(scan_interval.total_seconds() + 59) // 60)
from evohomeclient2 import EvohomeClient import evohomeclient2
try: try:
client = EvohomeClient( client = evo_data['client'] = evohomeclient2.EvohomeClient(
evo_data['params'][CONF_USERNAME], evo_data['params'][CONF_USERNAME],
evo_data['params'][CONF_PASSWORD], evo_data['params'][CONF_PASSWORD],
debug=False debug=False
) )
except HTTPError as err: except evohomeclient2.AuthenticationError as err:
if err.response.status_code == HTTP_BAD_REQUEST:
_LOGGER.error( _LOGGER.error(
"setup(): Failed to connect with the vendor's web servers. " "setup(): Failed to authenticate with the vendor's server. "
"Check your username (%s), and password are correct." "Check your username and password are correct. "
"Unable to continue. Resolve any errors and restart HA.", "Resolve any errors and restart HA. Message is: %s",
evo_data['params'][CONF_USERNAME] err
) )
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."
)
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 don't expect/handle any other HTTPErrors
return False return False
finally: # Redact username, password as no longer needed except requests.exceptions.ConnectionError:
evo_data['params'][CONF_USERNAME] = 'REDACTED' _LOGGER.error(
evo_data['params'][CONF_PASSWORD] = 'REDACTED' "setup(): Unable to connect with the vendor's server. "
"Check your network and the vendor's status page. "
"Resolve any errors and restart HA."
)
return False
finally: # Redact any config data that's no longer needed
for parameter in CONF_SECRETS:
evo_data['params'][parameter] = 'REDACTED' \
if evo_data['params'][parameter] else None
evo_data['client'] = client
evo_data['status'] = {} evo_data['status'] = {}
# Redact any installation data we'll never need # Redact any installation data that's no longer needed
for loc in client.installation_info: for loc in client.installation_info:
loc['locationInfo']['locationId'] = 'REDACTED' loc['locationInfo']['locationId'] = 'REDACTED'
loc['locationInfo']['locationOwner'] = 'REDACTED' loc['locationInfo']['locationOwner'] = 'REDACTED'
@ -120,18 +111,21 @@ def setup(hass, hass_config):
# Pull down the installation configuration # Pull down the installation configuration
loc_idx = evo_data['params'][CONF_LOCATION_IDX] loc_idx = evo_data['params'][CONF_LOCATION_IDX]
try: try:
evo_data['config'] = client.installation_info[loc_idx] evo_data['config'] = client.installation_info[loc_idx]
except IndexError: except IndexError:
_LOGGER.warning( _LOGGER.error(
"setup(): Parameter '%s'=%s, is outside its range (0-%s)", "setup(): config error, '%s' = %s, but its valid range is 0-%s. "
CONF_LOCATION_IDX, loc_idx, len(client.installation_info) - 1) "Unable to continue. Fix any configuration errors and restart HA.",
CONF_LOCATION_IDX, loc_idx, len(client.installation_info) - 1
)
return False return False
if _LOGGER.isEnabledFor(logging.DEBUG): if _LOGGER.isEnabledFor(logging.DEBUG):
tmp_loc = dict(evo_data['config']) tmp_loc = dict(evo_data['config'])
tmp_loc['locationInfo']['postcode'] = 'REDACTED' tmp_loc['locationInfo']['postcode'] = 'REDACTED'
if 'dhw' in tmp_loc[GWS][0][TCS][0]: # if this location has DHW... if 'dhw' in tmp_loc[GWS][0][TCS][0]: # if this location has DHW...
tmp_loc[GWS][0][TCS][0]['dhw'] = '...' tmp_loc[GWS][0][TCS][0]['dhw'] = '...'
@ -139,6 +133,11 @@ def setup(hass, hass_config):
load_platform(hass, 'climate', DOMAIN, {}, hass_config) load_platform(hass, 'climate', DOMAIN, {}, hass_config)
if 'dhw' in evo_data['config'][GWS][0][TCS][0]:
_LOGGER.warning(
"setup(): DHW found, but this component doesn't support DHW."
)
@callback @callback
def _first_update(event): def _first_update(event):
"""When HA has started, the hub knows to retrieve it's first update.""" """When HA has started, the hub knows to retrieve it's first update."""

View File

@ -2,22 +2,22 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from requests.exceptions import HTTPError import requests.exceptions
from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import ( from homeassistant.components.climate.const import (
STATE_AUTO, STATE_ECO, STATE_MANUAL, SUPPORT_AWAY_MODE, SUPPORT_ON_OFF, STATE_AUTO, STATE_ECO, STATE_MANUAL, SUPPORT_AWAY_MODE, SUPPORT_ON_OFF,
SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
from homeassistant.const import ( from homeassistant.const import (
CONF_SCAN_INTERVAL, HTTP_TOO_MANY_REQUESTS, PRECISION_HALVES, STATE_OFF, CONF_SCAN_INTERVAL, HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS,
TEMP_CELSIUS) PRECISION_HALVES, STATE_OFF, TEMP_CELSIUS)
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, dispatcher_send) async_dispatcher_connect, dispatcher_send)
from . import ( from . import (
CONF_LOCATION_IDX, DATA_EVOHOME, DISPATCHER_EVOHOME, EVO_CHILD, EVO_PARENT, CONF_LOCATION_IDX, DATA_EVOHOME, DISPATCHER_EVOHOME, EVO_CHILD, EVO_PARENT,
GWS, SCAN_INTERVAL_DEFAULT, TCS) GWS, TCS)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -81,7 +81,7 @@ async def async_setup_platform(hass, hass_config, async_add_entities,
# evohomeclient has exposed no means of accessing non-default location # evohomeclient has exposed no means of accessing non-default location
# (i.e. loc_idx > 0) other than using a protected member, such as below # (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 tcs_obj_ref = client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa: E501; pylint: disable=protected-access
_LOGGER.debug( _LOGGER.debug(
"Found Controller, id=%s [%s], name=%s (location_idx=%s)", "Found Controller, id=%s [%s], name=%s (location_idx=%s)",
@ -128,23 +128,43 @@ class EvoClimateDevice(ClimateDevice):
if packet['to'] & self._type and packet['signal'] == 'refresh': if packet['to'] & self._type and packet['signal'] == 'refresh':
self.async_schedule_update_ha_state(force_refresh=True) self.async_schedule_update_ha_state(force_refresh=True)
def _handle_requests_exceptions(self, err): def _handle_exception(self, err):
if err.response.status_code == HTTP_TOO_MANY_REQUESTS: try:
# execute a backoff: pause, and also reduce rate import evohomeclient2
old_interval = self._params[CONF_SCAN_INTERVAL] raise err
new_interval = min(old_interval, SCAN_INTERVAL_DEFAULT) * 2
self._params[CONF_SCAN_INTERVAL] = new_interval
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( _LOGGER.warning(
"API rate limit has been exceeded. Suspending polling for %s " "Unable to connect with the vendor's server. "
"seconds, and increasing '%s' from %s to %s seconds", "Check your network and the vendor's status page."
new_interval * 3, CONF_SCAN_INTERVAL, old_interval, )
new_interval)
self._timers['statusUpdated'] = datetime.now() + new_interval * 3 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: else:
raise err # we dont handle any other HTTPErrors raise # we don't expect/handle any other HTTPErrors
@property @property
def name(self) -> str: def name(self) -> str:
@ -239,7 +259,8 @@ class EvoZone(EvoClimateDevice):
@property @property
def current_temperature(self): def current_temperature(self):
"""Return the current temperature of the evohome Zone.""" """Return the current temperature of the evohome Zone."""
return self._status['temperatureStatus']['temperature'] return (self._status['temperatureStatus']['temperature']
if self._status['temperatureStatus']['isAvailable'] else None)
@property @property
def current_operation(self): def current_operation(self):
@ -284,9 +305,11 @@ class EvoZone(EvoClimateDevice):
- None for PermanentOverride (i.e. indefinitely) - None for PermanentOverride (i.e. indefinitely)
""" """
try: try:
import evohomeclient2
self._obj.set_temperature(temperature, until) self._obj.set_temperature(temperature, until)
except HTTPError as err: except (requests.exceptions.RequestException,
self._handle_exception("HTTPError", str(err)) # noqa: E501; pylint: disable=no-member evohomeclient2.AuthenticationError) as err:
self._handle_exception(err)
def set_temperature(self, **kwargs): def set_temperature(self, **kwargs):
"""Set new target temperature, indefinitely.""" """Set new target temperature, indefinitely."""
@ -334,9 +357,11 @@ class EvoZone(EvoClimateDevice):
def _set_operation_mode(self, operation_mode): def _set_operation_mode(self, operation_mode):
if operation_mode == EVO_FOLLOW: if operation_mode == EVO_FOLLOW:
try: try:
self._obj.cancel_temp_override(self._obj) import evohomeclient2
except HTTPError as err: self._obj.cancel_temp_override()
self._handle_exception("HTTPError", str(err)) # noqa: E501; pylint: disable=no-member except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
self._handle_exception(err)
elif operation_mode == EVO_TEMPOVER: elif operation_mode == EVO_TEMPOVER:
_LOGGER.error( _LOGGER.error(
@ -496,9 +521,11 @@ class EvoController(EvoClimateDevice):
def _set_operation_mode(self, operation_mode): def _set_operation_mode(self, operation_mode):
try: try:
import evohomeclient2
self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access
except HTTPError as err: except (requests.exceptions.RequestException,
self._handle_requests_exceptions(err) evohomeclient2.AuthenticationError) as err:
self._handle_exception(err)
def set_operation_mode(self, operation_mode): def set_operation_mode(self, operation_mode):
"""Set new target operation mode for the TCS. """Set new target operation mode for the TCS.
@ -532,10 +559,12 @@ class EvoController(EvoClimateDevice):
loc_idx = self._params[CONF_LOCATION_IDX] loc_idx = self._params[CONF_LOCATION_IDX]
try: try:
import evohomeclient2
self._status.update( self._status.update(
self._client.locations[loc_idx].status()[GWS][0][TCS][0]) self._client.locations[loc_idx].status()[GWS][0][TCS][0])
except HTTPError as err: # check if we've exceeded the api rate limit except (requests.exceptions.RequestException,
self._handle_requests_exceptions(err) evohomeclient2.AuthenticationError) as err:
self._handle_exception(err)
else: else:
self._timers['statusUpdated'] = datetime.now() self._timers['statusUpdated'] = datetime.now()
self._available = True self._available = True

View File

@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/climate.honeywell/ https://home-assistant.io/components/climate.honeywell/
""" """
import logging import logging
import socket
import datetime import datetime
import requests import requests
@ -21,7 +20,7 @@ from homeassistant.const import (
CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT,
ATTR_TEMPERATURE, CONF_REGION) ATTR_TEMPERATURE, CONF_REGION)
REQUIREMENTS = ['evohomeclient==0.2.8', 'somecomfort==0.5.2'] REQUIREMENTS = ['evohomeclient==0.3.2', 'somecomfort==0.5.2']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -78,9 +77,10 @@ def _setup_round(username, password, config, add_entities):
[RoundThermostat(evo_api, zone['id'], i == 0, away_temp)], [RoundThermostat(evo_api, zone['id'], i == 0, away_temp)],
True True
) )
except socket.error: except requests.exceptions.RequestException as err:
_LOGGER.error( _LOGGER.error(
"Connection error logging into the honeywell evohome web service") "Connection error logging into the honeywell evohome web service, "
"hint: %s", err)
return False return False
return True return True

View File

@ -414,7 +414,7 @@ eternalegypt==0.0.6
# homeassistant.components.evohome # homeassistant.components.evohome
# homeassistant.components.honeywell.climate # homeassistant.components.honeywell.climate
evohomeclient==0.2.8 evohomeclient==0.3.2
# homeassistant.components.dlib_face_detect.image_processing # homeassistant.components.dlib_face_detect.image_processing
# homeassistant.components.dlib_face_identify.image_processing # homeassistant.components.dlib_face_identify.image_processing

View File

@ -92,7 +92,7 @@ ephem==3.7.6.0
# homeassistant.components.evohome # homeassistant.components.evohome
# homeassistant.components.honeywell.climate # homeassistant.components.honeywell.climate
evohomeclient==0.2.8 evohomeclient==0.3.2
# homeassistant.components.feedreader # homeassistant.components.feedreader
feedparser-homeassistant==5.2.2.dev1 feedparser-homeassistant==5.2.2.dev1

View File

@ -1,9 +1,9 @@
"""The test the Honeywell thermostat module.""" """The test the Honeywell thermostat module."""
import socket
import unittest import unittest
from unittest import mock from unittest import mock
import voluptuous as vol import voluptuous as vol
import requests.exceptions
import somecomfort import somecomfort
from homeassistant.const import ( from homeassistant.const import (
@ -247,7 +247,8 @@ class TestHoneywell(unittest.TestCase):
honeywell.CONF_AWAY_TEMPERATURE: 20, honeywell.CONF_AWAY_TEMPERATURE: 20,
honeywell.CONF_REGION: 'eu', honeywell.CONF_REGION: 'eu',
} }
mock_evo.return_value.temperatures.side_effect = socket.error mock_evo.return_value.temperatures.side_effect = \
requests.exceptions.RequestException
add_entities = mock.MagicMock() add_entities = mock.MagicMock()
hass = mock.MagicMock() hass = mock.MagicMock()
assert not honeywell.setup_platform(hass, config, add_entities) assert not honeywell.setup_platform(hass, config, add_entities)