From c51dd64bd860b20e003b69c65222f5be03d4b73b Mon Sep 17 00:00:00 2001 From: Dan Smith Date: Sat, 13 Feb 2016 22:00:47 +0000 Subject: [PATCH 1/3] Convert Honeywell platform to use somecomfort library --- .coveragerc | 1 - .../components/thermostat/honeywell.py | 183 ++++-------------- requirements_all.txt | 3 + tests/components/thermostat/test_honeywell.py | 127 ++++++++++++ 4 files changed, 167 insertions(+), 147 deletions(-) create mode 100644 tests/components/thermostat/test_honeywell.py 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..8d72db0a288 100644 --- a/homeassistant/components/thermostat/honeywell.py +++ b/homeassistant/components/thermostat/honeywell.py @@ -9,21 +9,16 @@ 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' def _setup_round(username, password, config, add_devices): @@ -55,20 +50,21 @@ def _setup_round(username, password, config, add_devices): # 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()]) + add_devices([HoneywellUSThermostat(client, device) + for location in client.locations_by_id.values() + for device in location.devices_by_id.values()]) + return True # pylint: disable=unused-argument @@ -179,157 +175,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..0edc479d59a --- /dev/null +++ b/tests/components/thermostat/test_honeywell.py @@ -0,0 +1,127 @@ +""" +tests.components.thermostat.honeywell +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests the Honeywell thermostat module. +""" +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) + + +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) From 5921e65d838b39dc031fde6f524d395cba632eda Mon Sep 17 00:00:00 2001 From: Dan Smith Date: Sat, 13 Feb 2016 23:10:08 +0000 Subject: [PATCH 2/3] Allow specifying location and/or thermostat for Honeywell US This lets you optionally only add thermostats by location or specific device id, instead of all the thermostats in your account. This would be helpful if you have two devices in different houses (i.e vacation home), etc. --- .../components/thermostat/honeywell.py | 7 ++- tests/components/thermostat/test_honeywell.py | 63 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/thermostat/honeywell.py b/homeassistant/components/thermostat/honeywell.py index 8d72db0a288..4baa8e5c9ed 100644 --- a/homeassistant/components/thermostat/honeywell.py +++ b/homeassistant/components/thermostat/honeywell.py @@ -61,9 +61,14 @@ def _setup_us(username, password, config, add_devices): _LOGGER.error('Failed to initialize honeywell client: %s', str(ex)) return False + 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()]) + 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 diff --git a/tests/components/thermostat/test_honeywell.py b/tests/components/thermostat/test_honeywell.py index 0edc479d59a..5cb063846a5 100644 --- a/tests/components/thermostat/test_honeywell.py +++ b/tests/components/thermostat/test_honeywell.py @@ -67,6 +67,69 @@ class TestHoneywell(unittest.TestCase): 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) + class TestHoneywellUS(unittest.TestCase): def setup_method(self, method): From 0fbd947426c7d83c6fcbe9af80b75196dc06106e Mon Sep 17 00:00:00 2001 From: Dan Smith Date: Sun, 14 Feb 2016 01:05:18 +0000 Subject: [PATCH 3/3] Test Honeywell Round thermostat This includes two changes to the round code: - Return True on setup success - Break out the default away temp into a constant --- .../components/thermostat/honeywell.py | 4 +- tests/components/thermostat/test_honeywell.py | 120 ++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/thermostat/honeywell.py b/homeassistant/components/thermostat/honeywell.py index 4baa8e5c9ed..8411ba08fa3 100644 --- a/homeassistant/components/thermostat/honeywell.py +++ b/homeassistant/components/thermostat/honeywell.py @@ -19,13 +19,14 @@ REQUIREMENTS = ['evohomeclient==0.2.4', _LOGGER = logging.getLogger(__name__) CONF_AWAY_TEMP = "away_temperature" +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) @@ -45,6 +46,7 @@ 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 diff --git a/tests/components/thermostat/test_honeywell.py b/tests/components/thermostat/test_honeywell.py index 5cb063846a5..e0e3f1bd758 100644 --- a/tests/components/thermostat/test_honeywell.py +++ b/tests/components/thermostat/test_honeywell.py @@ -4,6 +4,7 @@ tests.components.thermostat.honeywell Tests the Honeywell thermostat module. """ +import socket import unittest from unittest import mock @@ -131,6 +132,125 @@ class TestHoneywell(unittest.TestCase): 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()