From 549abd9c7eb628be248c955216c99d2d5be480d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Kr=C3=BCger?= Date: Tue, 5 Jun 2018 20:06:25 +0200 Subject: [PATCH] Improved Fritz!Box thermostat support (#14789) --- .coveragerc | 2 +- homeassistant/components/climate/fritzbox.py | 25 ++- tests/components/climate/test_fritzbox.py | 172 +++++++++++++++++++ 3 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 tests/components/climate/test_fritzbox.py diff --git a/.coveragerc b/.coveragerc index dfbbb232efc..c8958d98178 100644 --- a/.coveragerc +++ b/.coveragerc @@ -97,7 +97,7 @@ omit = homeassistant/components/*/envisalink.py homeassistant/components/fritzbox.py - homeassistant/components/*/fritzbox.py + homeassistant/components/switch/fritzbox.py homeassistant/components/eufy.py homeassistant/components/*/eufy.py diff --git a/homeassistant/components/climate/fritzbox.py b/homeassistant/components/climate/fritzbox.py index 839da8c9d53..fa3ca31c770 100755 --- a/homeassistant/components/climate/fritzbox.py +++ b/homeassistant/components/climate/fritzbox.py @@ -13,21 +13,27 @@ from homeassistant.components.fritzbox import ( ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_BATTERY_LOW, ATTR_STATE_LOCKED) from homeassistant.components.climate import ( ATTR_OPERATION_MODE, ClimateDevice, STATE_ECO, STATE_HEAT, STATE_MANUAL, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) + STATE_OFF, STATE_ON, SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE) from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_HALVES, TEMP_CELSIUS) - DEPENDENCIES = ['fritzbox'] _LOGGER = logging.getLogger(__name__) SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE) -OPERATION_LIST = [STATE_HEAT, STATE_ECO] +OPERATION_LIST = [STATE_HEAT, STATE_ECO, STATE_OFF, STATE_ON] MIN_TEMPERATURE = 8 MAX_TEMPERATURE = 28 +# special temperatures for on/off in Fritz!Box API (modified by pyfritzhome) +ON_API_TEMPERATURE = 127.0 +OFF_API_TEMPERATURE = 126.5 +ON_REPORT_SET_TEMPERATURE = 30.0 +OFF_REPORT_SET_TEMPERATURE = 0.0 + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Fritzbox smarthome thermostat platform.""" @@ -88,6 +94,9 @@ class FritzboxThermostat(ClimateDevice): @property def target_temperature(self): """Return the temperature we try to reach.""" + if self._target_temperature in (ON_API_TEMPERATURE, + OFF_API_TEMPERATURE): + return None return self._target_temperature def set_temperature(self, **kwargs): @@ -102,9 +111,13 @@ class FritzboxThermostat(ClimateDevice): @property def current_operation(self): """Return the current operation mode.""" + if self._target_temperature == ON_API_TEMPERATURE: + return STATE_ON + if self._target_temperature == OFF_API_TEMPERATURE: + return STATE_OFF if self._target_temperature == self._comfort_temperature: return STATE_HEAT - elif self._target_temperature == self._eco_temperature: + if self._target_temperature == self._eco_temperature: return STATE_ECO return STATE_MANUAL @@ -119,6 +132,10 @@ class FritzboxThermostat(ClimateDevice): self.set_temperature(temperature=self._comfort_temperature) elif operation_mode == STATE_ECO: self.set_temperature(temperature=self._eco_temperature) + elif operation_mode == STATE_OFF: + self.set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) + elif operation_mode == STATE_ON: + self.set_temperature(temperature=ON_REPORT_SET_TEMPERATURE) @property def min_temp(self): diff --git a/tests/components/climate/test_fritzbox.py b/tests/components/climate/test_fritzbox.py new file mode 100644 index 00000000000..ccffef9e547 --- /dev/null +++ b/tests/components/climate/test_fritzbox.py @@ -0,0 +1,172 @@ +"""The tests for the demo climate component.""" +import unittest +from unittest.mock import Mock, patch + +import requests + +from homeassistant.components.climate.fritzbox import FritzboxThermostat + + +class TestFritzboxClimate(unittest.TestCase): + """Test Fritz!Box heating thermostats.""" + + def setUp(self): + """Create a mock device to test on.""" + self.device = Mock() + self.device.name = 'Test Thermostat' + self.device.actual_temperature = 18.0 + self.device.target_temperature = 19.5 + self.device.comfort_temperature = 22.0 + self.device.eco_temperature = 16.0 + self.device.present = True + self.device.device_lock = True + self.device.lock = False + self.device.battery_low = True + self.device.set_target_temperature = Mock() + self.device.update = Mock() + mock_fritz = Mock() + mock_fritz.login = Mock() + self.thermostat = FritzboxThermostat(self.device, mock_fritz) + + def test_init(self): + """Test instance creation.""" + self.assertEqual(18.0, self.thermostat._current_temperature) + self.assertEqual(19.5, self.thermostat._target_temperature) + self.assertEqual(22.0, self.thermostat._comfort_temperature) + self.assertEqual(16.0, self.thermostat._eco_temperature) + + def test_supported_features(self): + """Test supported features property.""" + self.assertEqual(129, self.thermostat.supported_features) + + def test_available(self): + """Test available property.""" + self.assertTrue(self.thermostat.available) + self.thermostat._device.present = False + self.assertFalse(self.thermostat.available) + + def test_name(self): + """Test name property.""" + self.assertEqual('Test Thermostat', self.thermostat.name) + + def test_temperature_unit(self): + """Test temperature_unit property.""" + self.assertEqual('°C', self.thermostat.temperature_unit) + + def test_precision(self): + """Test precision property.""" + self.assertEqual(0.5, self.thermostat.precision) + + def test_current_temperature(self): + """Test current_temperature property incl. special temperatures.""" + self.assertEqual(18, self.thermostat.current_temperature) + + def test_target_temperature(self): + """Test target_temperature property.""" + self.assertEqual(19.5, self.thermostat.target_temperature) + + self.thermostat._target_temperature = 126.5 + self.assertEqual(None, self.thermostat.target_temperature) + + self.thermostat._target_temperature = 127.0 + self.assertEqual(None, self.thermostat.target_temperature) + + @patch.object(FritzboxThermostat, 'set_operation_mode') + def test_set_temperature_operation_mode(self, mock_set_op): + """Test set_temperature by operation_mode.""" + self.thermostat.set_temperature(operation_mode='test_mode') + mock_set_op.assert_called_once_with('test_mode') + + def test_set_temperature_temperature(self): + """Test set_temperature by temperature.""" + self.thermostat.set_temperature(temperature=23.0) + self.thermostat._device.set_target_temperature.\ + assert_called_once_with(23.0) + + @patch.object(FritzboxThermostat, 'set_operation_mode') + def test_set_temperature_none(self, mock_set_op): + """Test set_temperature with no arguments.""" + self.thermostat.set_temperature() + mock_set_op.assert_not_called() + self.thermostat._device.set_target_temperature.assert_not_called() + + @patch.object(FritzboxThermostat, 'set_operation_mode') + def test_set_temperature_operation_mode_precedence(self, mock_set_op): + """Test set_temperature for precedence of operation_mode arguement.""" + self.thermostat.set_temperature(operation_mode='test_mode', + temperature=23.0) + mock_set_op.assert_called_once_with('test_mode') + self.thermostat._device.set_target_temperature.assert_not_called() + + def test_current_operation(self): + """Test operation mode property for different temperatures.""" + self.thermostat._target_temperature = 127.0 + self.assertEqual('on', self.thermostat.current_operation) + self.thermostat._target_temperature = 126.5 + self.assertEqual('off', self.thermostat.current_operation) + self.thermostat._target_temperature = 22.0 + self.assertEqual('heat', self.thermostat.current_operation) + self.thermostat._target_temperature = 16.0 + self.assertEqual('eco', self.thermostat.current_operation) + self.thermostat._target_temperature = 12.5 + self.assertEqual('manual', self.thermostat.current_operation) + + def test_operation_list(self): + """Test operation_list property.""" + self.assertEqual(['heat', 'eco', 'off', 'on'], + self.thermostat.operation_list) + + @patch.object(FritzboxThermostat, 'set_temperature') + def test_set_operation_mode(self, mock_set_temp): + """Test set_operation_mode by all modes and with a non-existing one.""" + values = { + 'heat': 22.0, + 'eco': 16.0, + 'on': 30.0, + 'off': 0.0} + for mode, temp in values.items(): + print(mode, temp) + + mock_set_temp.reset_mock() + self.thermostat.set_operation_mode(mode) + mock_set_temp.assert_called_once_with(temperature=temp) + + mock_set_temp.reset_mock() + self.thermostat.set_operation_mode('non_existing_mode') + mock_set_temp.assert_not_called() + + def test_min_max_temperature(self): + """Test min_temp and max_temp properties.""" + self.assertEqual(8.0, self.thermostat.min_temp) + self.assertEqual(28.0, self.thermostat.max_temp) + + def test_device_state_attributes(self): + """Test device_state property.""" + attr = self.thermostat.device_state_attributes + self.assertEqual(attr['device_locked'], True) + self.assertEqual(attr['locked'], False) + self.assertEqual(attr['battery_low'], True) + + def test_update(self): + """Test update function.""" + device = Mock() + device.update = Mock() + device.actual_temperature = 10.0 + device.target_temperature = 11.0 + device.comfort_temperature = 12.0 + device.eco_temperature = 13.0 + self.thermostat._device = device + + self.thermostat.update() + + device.update.assert_called_once_with() + self.assertEqual(10.0, self.thermostat._current_temperature) + self.assertEqual(11.0, self.thermostat._target_temperature) + self.assertEqual(12.0, self.thermostat._comfort_temperature) + self.assertEqual(13.0, self.thermostat._eco_temperature) + + def test_update_http_error(self): + """Test exception handling of update function.""" + self.device.update.side_effect = requests.exceptions.HTTPError + self.thermostat.update() + self.thermostat._fritz.login.assert_called_once_with()