diff --git a/.coveragerc b/.coveragerc index 06cfc7d7471..27016770693 100644 --- a/.coveragerc +++ b/.coveragerc @@ -149,7 +149,6 @@ omit = homeassistant/components/switch/wemo.py homeassistant/components/thermostat/heatmiser.py homeassistant/components/thermostat/homematic.py - homeassistant/components/thermostat/honeywell.py homeassistant/components/thermostat/proliphix.py homeassistant/components/thermostat/radiotherm.py diff --git a/homeassistant/components/thermostat/honeywell.py b/homeassistant/components/thermostat/honeywell.py index d4365512e96..8411ba08fa3 100644 --- a/homeassistant/components/thermostat/honeywell.py +++ b/homeassistant/components/thermostat/honeywell.py @@ -9,28 +9,24 @@ https://home-assistant.io/components/thermostat.honeywell/ import logging import socket -import requests - from homeassistant.components.thermostat import ThermostatDevice from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, TEMP_CELCIUS, TEMP_FAHRENHEIT) -REQUIREMENTS = ['evohomeclient==0.2.4'] +REQUIREMENTS = ['evohomeclient==0.2.4', + 'somecomfort==0.2.0'] _LOGGER = logging.getLogger(__name__) CONF_AWAY_TEMP = "away_temperature" -US_SYSTEM_SWITCH_POSITIONS = {1: 'Heat', - 2: 'Off', - 3: 'Cool'} -US_BASEURL = 'https://mytotalconnectcomfort.com/portal' +DEFAULT_AWAY_TEMP = 16 def _setup_round(username, password, config, add_devices): from evohomeclient import EvohomeClient try: - away_temp = float(config.get(CONF_AWAY_TEMP, 16)) + away_temp = float(config.get(CONF_AWAY_TEMP, DEFAULT_AWAY_TEMP)) except ValueError: _LOGGER.error("value entered for item %s should convert to a number", CONF_AWAY_TEMP) @@ -50,25 +46,32 @@ def _setup_round(username, password, config, add_devices): "Connection error logging into the honeywell evohome web service" ) return False + return True # config will be used later # pylint: disable=unused-argument def _setup_us(username, password, config, add_devices): - session = requests.Session() - if not HoneywellUSThermostat.do_login(session, username, password): + import somecomfort + + try: + client = somecomfort.SomeComfort(username, password) + except somecomfort.AuthError: _LOGGER.error('Failed to login to honeywell account %s', username) return False - - thermostats = HoneywellUSThermostat.get_devices(session) - if not thermostats: - _LOGGER.error('No thermostats found in account %s', username) + except somecomfort.SomeComfortError as ex: + _LOGGER.error('Failed to initialize honeywell client: %s', str(ex)) return False - add_devices([HoneywellUSThermostat(id_, username, password, - name=name, - session=session) - for id_, name in thermostats.items()]) + dev_id = config.get('thermostat') + loc_id = config.get('location') + + add_devices([HoneywellUSThermostat(client, device) + for location in client.locations_by_id.values() + for device in location.devices_by_id.values() + if ((not loc_id or location.locationid == loc_id) and + (not dev_id or device.deviceid == dev_id))]) + return True # pylint: disable=unused-argument @@ -179,157 +182,52 @@ class RoundThermostat(ThermostatDevice): class HoneywellUSThermostat(ThermostatDevice): """ Represents a Honeywell US Thermostat. """ - # pylint: disable=too-many-arguments - def __init__(self, ident, username, password, name='honeywell', - session=None): - self._ident = ident - self._username = username - self._password = password - self._name = name - if not session: - self._session = requests.Session() - self._login() - self._session = session - # Maybe this should be configurable? - self._timeout = 30 - # Yeah, really. - self._session.headers['X-Requested-With'] = 'XMLHttpRequest' - self._update() - - @staticmethod - def get_devices(session): - """ Return a dict of devices. - - :param session: A session already primed from do_login - :returns: A dict of devices like: device_id=name - """ - url = '%s/Location/GetLocationListData' % US_BASEURL - resp = session.post(url, params={'page': 1, 'filter': ''}) - if resp.status_code == 200: - return {device['DeviceID']: device['Name'] - for device in resp.json()[0]['Devices']} - else: - return None - - @staticmethod - def do_login(session, username, password, timeout=30): - """ Log into mytotalcomfort.com - - :param session: A requests.Session object to use - :param username: Account username - :param password: Account password - :param timeout: Timeout to use with requests - :returns: A boolean indicating success - """ - session.headers['X-Requested-With'] = 'XMLHttpRequest' - session.get(US_BASEURL, timeout=timeout) - params = {'UserName': username, - 'Password': password, - 'RememberMe': 'false', - 'timeOffset': 480} - resp = session.post(US_BASEURL, params=params, - timeout=timeout) - if resp.status_code != 200: - _LOGGER('Login failed for user %s', username) - return False - else: - return True - - def _login(self): - return self.do_login(self._session, self._username, self._password, - timeout=self._timeout) - - def _keepalive(self): - resp = self._session.get('%s/Account/KeepAlive') - if resp.status_code != 200: - if self._login(): - _LOGGER.info('Re-logged into honeywell account') - else: - _LOGGER.error('Failed to re-login to honeywell account') - return False - else: - _LOGGER.debug('Keepalive succeeded') - return True - - def _get_data(self): - if not self._keepalive: - return {'error': 'not logged in'} - url = '%s/Device/CheckDataSession/%s' % (US_BASEURL, self._ident) - resp = self._session.get(url, timeout=self._timeout) - if resp.status_code < 300: - return resp.json() - else: - return {'error': resp.status_code} - - def _set_data(self, data): - if not self._keepalive: - return {'error': 'not logged in'} - url = '%s/Device/SubmitControlScreenChanges' % US_BASEURL - data['DeviceID'] = self._ident - resp = self._session.post(url, data=data, timeout=self._timeout) - if resp.status_code < 300: - return resp.json() - else: - return {'error': resp.status_code} - - def _update(self): - data = self._get_data()['latestData'] - if 'error' not in data: - self._data = data + def __init__(self, client, device): + self._client = client + self._device = device @property def is_fan_on(self): - return self._data['fanData']['fanIsRunning'] + return self._device.fan_running @property def name(self): - return self._name + return self._device.name @property def unit_of_measurement(self): - unit = self._data['uiData']['DisplayUnits'] - if unit == 'F': - return TEMP_FAHRENHEIT - else: - return TEMP_CELCIUS + return (TEMP_CELCIUS if self._device.temperature_unit == 'C' + else TEMP_FAHRENHEIT) @property def current_temperature(self): - self._update() - return self._data['uiData']['DispTemperature'] + self._device.refresh() + return self._device.current_temperature @property def target_temperature(self): - setpoint = US_SYSTEM_SWITCH_POSITIONS.get( - self._data['uiData']['SystemSwitchPosition'], - 'Off') - return self._data['uiData']['%sSetpoint' % setpoint] + if self._device.system_mode == 'cool': + return self._device.setpoint_cool + else: + return self._device.setpoint_heat def set_temperature(self, temperature): """ Set target temperature. """ - data = {'SystemSwitch': None, - 'HeatSetpoint': None, - 'CoolSetpoint': None, - 'HeatNextPeriod': None, - 'CoolNextPeriod': None, - 'StatusHeat': None, - 'StatusCool': None, - 'FanMode': None} - setpoint = US_SYSTEM_SWITCH_POSITIONS.get( - self._data['uiData']['SystemSwitchPosition'], - 'Off') - data['%sSetpoint' % setpoint] = temperature - self._set_data(data) + import somecomfort + try: + if self._device.system_mode == 'cool': + self._device.setpoint_cool = temperature + else: + self._device.setpoint_heat = temperature + except somecomfort.SomeComfortError: + _LOGGER.error('Temperature %.1f out of range', temperature) @property def device_state_attributes(self): """ Return device specific state attributes. """ - fanmodes = {0: "auto", - 1: "on", - 2: "circulate"} - return {"fan": (self._data['fanData']['fanIsRunning'] and - 'running' or 'idle'), - "fanmode": fanmodes[self._data['fanData']['fanMode']]} + return {'fan': (self.is_fan_on and 'running' or 'idle'), + 'fanmode': self._device.fan_mode, + 'system_mode': self._device.system_mode} def turn_away_mode_on(self): pass diff --git a/requirements_all.txt b/requirements_all.txt index bfd1e8c7c45..ef448f2c4c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -230,6 +230,9 @@ sleekxmpp==1.3.1 # homeassistant.components.media_player.snapcast snapcast==1.1.1 +# homeassistant.components.thermostat.honeywell +somecomfort==0.2.0 + # homeassistant.components.sensor.speedtest speedtest-cli==0.3.4 diff --git a/tests/components/thermostat/test_honeywell.py b/tests/components/thermostat/test_honeywell.py new file mode 100644 index 00000000000..e0e3f1bd758 --- /dev/null +++ b/tests/components/thermostat/test_honeywell.py @@ -0,0 +1,310 @@ +""" +tests.components.thermostat.honeywell +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests the Honeywell thermostat module. +""" +import socket +import unittest +from unittest import mock + +import somecomfort + +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, + TEMP_CELCIUS, TEMP_FAHRENHEIT) +import homeassistant.components.thermostat.honeywell as honeywell + + +class TestHoneywell(unittest.TestCase): + @mock.patch('somecomfort.SomeComfort') + @mock.patch('homeassistant.components.thermostat.' + 'honeywell.HoneywellUSThermostat') + def test_setup_us(self, mock_ht, mock_sc): + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'us', + } + hass = mock.MagicMock() + add_devices = mock.MagicMock() + + locations = [ + mock.MagicMock(), + mock.MagicMock(), + ] + devices_1 = [mock.MagicMock()] + devices_2 = [mock.MagicMock(), mock.MagicMock] + mock_sc.return_value.locations_by_id.values.return_value = \ + locations + locations[0].devices_by_id.values.return_value = devices_1 + locations[1].devices_by_id.values.return_value = devices_2 + + result = honeywell.setup_platform(hass, config, add_devices) + self.assertTrue(result) + mock_sc.assert_called_once_with('user', 'pass') + mock_ht.assert_has_calls([ + mock.call(mock_sc.return_value, devices_1[0]), + mock.call(mock_sc.return_value, devices_2[0]), + mock.call(mock_sc.return_value, devices_2[1]), + ]) + + @mock.patch('somecomfort.SomeComfort') + def test_setup_us_failures(self, mock_sc): + hass = mock.MagicMock() + add_devices = mock.MagicMock() + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'us', + } + + mock_sc.side_effect = somecomfort.AuthError + result = honeywell.setup_platform(hass, config, add_devices) + self.assertFalse(result) + self.assertFalse(add_devices.called) + + mock_sc.side_effect = somecomfort.SomeComfortError + result = honeywell.setup_platform(hass, config, add_devices) + self.assertFalse(result) + self.assertFalse(add_devices.called) + + @mock.patch('somecomfort.SomeComfort') + @mock.patch('homeassistant.components.thermostat.' + 'honeywell.HoneywellUSThermostat') + def _test_us_filtered_devices(self, mock_ht, mock_sc, loc=None, dev=None): + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'us', + 'location': loc, + 'thermostat': dev, + } + locations = { + 1: mock.MagicMock(locationid=mock.sentinel.loc1, + devices_by_id={ + 11: mock.MagicMock( + deviceid=mock.sentinel.loc1dev1), + 12: mock.MagicMock( + deviceid=mock.sentinel.loc1dev2), + }), + 2: mock.MagicMock(locationid=mock.sentinel.loc2, + devices_by_id={ + 21: mock.MagicMock( + deviceid=mock.sentinel.loc2dev1), + }), + 3: mock.MagicMock(locationid=mock.sentinel.loc3, + devices_by_id={ + 31: mock.MagicMock( + deviceid=mock.sentinel.loc3dev1), + }), + } + mock_sc.return_value = mock.MagicMock(locations_by_id=locations) + hass = mock.MagicMock() + add_devices = mock.MagicMock() + self.assertEqual(True, + honeywell.setup_platform(hass, config, add_devices)) + + return mock_ht.call_args_list, mock_sc + + def test_us_filtered_thermostat_1(self): + result, client = self._test_us_filtered_devices( + dev=mock.sentinel.loc1dev1) + devices = [x[0][1].deviceid for x in result] + self.assertEqual([mock.sentinel.loc1dev1], devices) + + def test_us_filtered_thermostat_2(self): + result, client = self._test_us_filtered_devices( + dev=mock.sentinel.loc2dev1) + devices = [x[0][1].deviceid for x in result] + self.assertEqual([mock.sentinel.loc2dev1], devices) + + def test_us_filtered_location_1(self): + result, client = self._test_us_filtered_devices( + loc=mock.sentinel.loc1) + devices = [x[0][1].deviceid for x in result] + self.assertEqual([mock.sentinel.loc1dev1, + mock.sentinel.loc1dev2], devices) + + def test_us_filtered_location_2(self): + result, client = self._test_us_filtered_devices( + loc=mock.sentinel.loc2) + devices = [x[0][1].deviceid for x in result] + self.assertEqual([mock.sentinel.loc2dev1], devices) + + + @mock.patch('evohomeclient.EvohomeClient') + @mock.patch('homeassistant.components.thermostat.honeywell.' + 'RoundThermostat') + def test_eu_setup_full_config(self, mock_round, mock_evo): + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + honeywell.CONF_AWAY_TEMP: 20, + 'region': 'eu', + } + mock_evo.return_value.temperatures.return_value = [ + {'id': 'foo'}, {'id': 'bar'}] + hass = mock.MagicMock() + add_devices = mock.MagicMock() + self.assertTrue(honeywell.setup_platform(hass, config, add_devices)) + mock_evo.assert_called_once_with('user', 'pass') + mock_evo.return_value.temperatures.assert_called_once_with( + force_refresh=True) + mock_round.assert_has_calls([ + mock.call(mock_evo.return_value, 'foo', True, 20), + mock.call(mock_evo.return_value, 'bar', False, 20), + ]) + self.assertEqual(2, add_devices.call_count) + + @mock.patch('evohomeclient.EvohomeClient') + @mock.patch('homeassistant.components.thermostat.honeywell.' + 'RoundThermostat') + def test_eu_setup_partial_config(self, mock_round, mock_evo): + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'eu', + } + mock_evo.return_value.temperatures.return_value = [ + {'id': 'foo'}, {'id': 'bar'}] + hass = mock.MagicMock() + add_devices = mock.MagicMock() + self.assertTrue(honeywell.setup_platform(hass, config, add_devices)) + default = honeywell.DEFAULT_AWAY_TEMP + mock_round.assert_has_calls([ + mock.call(mock_evo.return_value, 'foo', True, default), + mock.call(mock_evo.return_value, 'bar', False, default), + ]) + + @mock.patch('evohomeclient.EvohomeClient') + @mock.patch('homeassistant.components.thermostat.honeywell.' + 'RoundThermostat') + def test_eu_setup_bad_temp(self, mock_round, mock_evo): + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + honeywell.CONF_AWAY_TEMP: 'ponies', + 'region': 'eu', + } + self.assertFalse(honeywell.setup_platform(None, config, None)) + + @mock.patch('evohomeclient.EvohomeClient') + @mock.patch('homeassistant.components.thermostat.honeywell.' + 'RoundThermostat') + def test_eu_setup_error(self, mock_round, mock_evo): + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + honeywell.CONF_AWAY_TEMP: 20, + 'region': 'eu', + } + mock_evo.return_value.temperatures.side_effect = socket.error + add_devices = mock.MagicMock() + hass = mock.MagicMock() + self.assertFalse(honeywell.setup_platform(hass, config, add_devices)) + + +class TestHoneywellRound(unittest.TestCase): + def setup_method(self, method): + def fake_temperatures(force_refresh=None): + temps = [ + {'id': '1', 'temp': 20, 'setpoint': 21, + 'thermostat': 'main', 'name': 'House'}, + {'id': '2', 'temp': 21, 'setpoint': 22, + 'thermostat': 'DOMESTIC_HOT_WATER'}, + ] + return temps + + self.device = mock.MagicMock() + self.device.temperatures.side_effect = fake_temperatures + self.round1 = honeywell.RoundThermostat(self.device, '1', + True, 16) + self.round2 = honeywell.RoundThermostat(self.device, '2', + False, 17) + + def test_attributes(self): + self.assertEqual('House', self.round1.name) + self.assertEqual(TEMP_CELCIUS, self.round1.unit_of_measurement) + self.assertEqual(20, self.round1.current_temperature) + self.assertEqual(21, self.round1.target_temperature) + self.assertFalse(self.round1.is_away_mode_on) + + self.assertEqual('Hot Water', self.round2.name) + self.assertEqual(TEMP_CELCIUS, self.round2.unit_of_measurement) + self.assertEqual(21, self.round2.current_temperature) + self.assertEqual(None, self.round2.target_temperature) + self.assertFalse(self.round2.is_away_mode_on) + + def test_away_mode(self): + self.assertFalse(self.round1.is_away_mode_on) + self.round1.turn_away_mode_on() + self.assertTrue(self.round1.is_away_mode_on) + self.device.set_temperature.assert_called_once_with('House', 16) + + self.device.set_temperature.reset_mock() + self.round1.turn_away_mode_off() + self.assertFalse(self.round1.is_away_mode_on) + self.device.cancel_temp_override.assert_called_once_with('House') + + def test_set_temperature(self): + self.round1.set_temperature(25) + self.device.set_temperature.assert_called_once_with('House', 25) + + +class TestHoneywellUS(unittest.TestCase): + def setup_method(self, method): + self.client = mock.MagicMock() + self.device = mock.MagicMock() + self.honeywell = honeywell.HoneywellUSThermostat( + self.client, self.device) + + self.device.fan_running = True + self.device.name = 'test' + self.device.temperature_unit = 'F' + self.device.current_temperature = 72 + self.device.setpoint_cool = 78 + self.device.setpoint_heat = 65 + self.device.system_mode = 'heat' + self.device.fan_mode = 'auto' + + def test_properties(self): + self.assertTrue(self.honeywell.is_fan_on) + self.assertEqual('test', self.honeywell.name) + self.assertEqual(72, self.honeywell.current_temperature) + + def test_unit_of_measurement(self): + self.assertEqual(TEMP_FAHRENHEIT, self.honeywell.unit_of_measurement) + self.device.temperature_unit = 'C' + self.assertEqual(TEMP_CELCIUS, self.honeywell.unit_of_measurement) + + def test_target_temp(self): + self.assertEqual(65, self.honeywell.target_temperature) + self.device.system_mode = 'cool' + self.assertEqual(78, self.honeywell.target_temperature) + + def test_set_temp(self): + self.honeywell.set_temperature(70) + self.assertEqual(70, self.device.setpoint_heat) + self.assertEqual(70, self.honeywell.target_temperature) + + self.device.system_mode = 'cool' + self.assertEqual(78, self.honeywell.target_temperature) + self.honeywell.set_temperature(74) + self.assertEqual(74, self.device.setpoint_cool) + self.assertEqual(74, self.honeywell.target_temperature) + + def test_set_temp_fail(self): + self.device.setpoint_heat = mock.MagicMock( + side_effect=somecomfort.SomeComfortError) + self.honeywell.set_temperature(123) + + def test_attributes(self): + expected = { + 'fan': 'running', + 'fanmode': 'auto', + 'system_mode': 'heat', + } + self.assertEqual(expected, self.honeywell.device_state_attributes) + expected['fan'] = 'idle' + self.device.fan_running = False + self.assertEqual(expected, self.honeywell.device_state_attributes)