From 3436676de2bcd2dd5a7d24ffbe2991856cce8fe1 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 4 Jan 2018 20:05:11 +0200 Subject: [PATCH] Updated generic thermostat to respect operation_mode and added away mode (#11445) * Updated generic thermostat to respect operation_mode and added away mode * Updated tests to include away mode and corrected init problem for sensor state Added more tests to improve coverage and corrected again lint errors Fixed new test by moving to correct package Fixed bug not restoring away mode on restart * Added support for idle on interface through state * Added back initial_operation_mode and modified away_temp to be only one for now * Fixed houndci-bot errors * Added back check for None on restore temperature * Fixed failing tests as well * Removed unused definitions from tests * Added use case for no initial temperature and no previously saved temperature --- .../components/climate/generic_thermostat.py | 109 +++++++++++++----- .../climate/test_generic_thermostat.py | 97 +++++++++++----- 2 files changed, 149 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 6574a4d5396..fdfe56ca62c 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -12,9 +12,9 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.components.climate import ( - STATE_HEAT, STATE_COOL, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA, - STATE_AUTO, ATTR_OPERATION_MODE, SUPPORT_OPERATION_MODE, - SUPPORT_TARGET_TEMPERATURE) + STATE_HEAT, STATE_COOL, STATE_IDLE, STATE_AUTO, ClimateDevice, + ATTR_OPERATION_MODE, ATTR_AWAY_MODE, SUPPORT_OPERATION_MODE, + SUPPORT_AWAY_MODE, SUPPORT_TARGET_TEMPERATURE, PLATFORM_SCHEMA) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) @@ -30,6 +30,7 @@ DEPENDENCIES = ['switch', 'sensor'] DEFAULT_TOLERANCE = 0.3 DEFAULT_NAME = 'Generic Thermostat' +DEFAULT_AWAY_TEMP = 16 CONF_HEATER = 'heater' CONF_SENSOR = 'target_sensor' @@ -42,7 +43,9 @@ CONF_COLD_TOLERANCE = 'cold_tolerance' CONF_HOT_TOLERANCE = 'hot_tolerance' CONF_KEEP_ALIVE = 'keep_alive' CONF_INITIAL_OPERATION_MODE = 'initial_operation_mode' -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE +CONF_AWAY_TEMP = 'away_temp' +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE | + SUPPORT_OPERATION_MODE) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HEATER): cv.entity_id, @@ -60,7 +63,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_KEEP_ALIVE): vol.All( cv.time_period, cv.positive_timedelta), vol.Optional(CONF_INITIAL_OPERATION_MODE): - vol.In([STATE_AUTO, STATE_OFF]) + vol.In([STATE_AUTO, STATE_OFF]), + vol.Optional(CONF_AWAY_TEMP, + default=DEFAULT_AWAY_TEMP): vol.Coerce(float) }) @@ -79,11 +84,12 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hot_tolerance = config.get(CONF_HOT_TOLERANCE) keep_alive = config.get(CONF_KEEP_ALIVE) initial_operation_mode = config.get(CONF_INITIAL_OPERATION_MODE) + away_temp = config.get(CONF_AWAY_TEMP) async_add_devices([GenericThermostat( hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, cold_tolerance, - hot_tolerance, keep_alive, initial_operation_mode)]) + hot_tolerance, keep_alive, initial_operation_mode, away_temp)]) class GenericThermostat(ClimateDevice): @@ -92,7 +98,7 @@ class GenericThermostat(ClimateDevice): def __init__(self, hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, cold_tolerance, hot_tolerance, keep_alive, - initial_operation_mode): + initial_operation_mode, away_temp): """Initialize the thermostat.""" self.hass = hass self._name = name @@ -103,17 +109,26 @@ class GenericThermostat(ClimateDevice): self._hot_tolerance = hot_tolerance self._keep_alive = keep_alive self._initial_operation_mode = initial_operation_mode + self._saved_target_temp = target_temp if target_temp is not None \ + else away_temp + if self.ac_mode: + self._current_operation = STATE_COOL + self._operation_list = [STATE_COOL, STATE_OFF] + else: + self._current_operation = STATE_HEAT + self._operation_list = [STATE_HEAT, STATE_OFF] if initial_operation_mode == STATE_OFF: self._enabled = False else: self._enabled = True - self._active = False self._cur_temp = None self._min_temp = min_temp self._max_temp = max_temp self._target_temp = target_temp self._unit = hass.config.units.temperature_unit + self._away_temp = away_temp + self._is_away = False async_track_state_change( hass, sensor_entity_id, self._async_sensor_changed) @@ -124,10 +139,6 @@ class GenericThermostat(ClimateDevice): async_track_time_interval( hass, self._async_keep_alive, self._keep_alive) - sensor_state = hass.states.get(sensor_entity_id) - if sensor_state: - self._async_update_temp(sensor_state) - @asyncio.coroutine def async_added_to_hass(self): """Run when entity about to be added.""" @@ -137,14 +148,37 @@ class GenericThermostat(ClimateDevice): if old_state is not None: # If we have no initial temperature, restore if self._target_temp is None: - self._target_temp = float( - old_state.attributes[ATTR_TEMPERATURE]) - - # If we have no initial operation mode, restore + # If we have a previously saved temperature + if old_state.attributes[ATTR_TEMPERATURE] is None: + if self.ac_mode: + self._target_temp = self.max_temp + else: + self._target_temp = self.min_temp + _LOGGER.warning('Undefined target temperature, \ + falling back to %s', self._target_temp) + else: + self._target_temp = float( + old_state.attributes[ATTR_TEMPERATURE]) + self._is_away = True if str( + old_state.attributes[ATTR_AWAY_MODE]) == STATE_ON else False + if old_state.attributes[ATTR_OPERATION_MODE] == STATE_OFF: + self._current_operation = STATE_OFF + self._enabled = False if self._initial_operation_mode is None: if old_state.attributes[ATTR_OPERATION_MODE] == STATE_OFF: self._enabled = False + @property + def state(self): + """Return the current state.""" + if self._is_device_active: + return self.current_operation + else: + if self._enabled: + return STATE_IDLE + else: + return STATE_OFF + @property def should_poll(self): """Return the polling state.""" @@ -167,15 +201,8 @@ class GenericThermostat(ClimateDevice): @property def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - if not self._enabled: - return STATE_OFF - if self.ac_mode: - cooling = self._active and self._is_device_active - return STATE_COOL if cooling else STATE_IDLE - - heating = self._active and self._is_device_active - return STATE_HEAT if heating else STATE_IDLE + """Return current operation.""" + return self._current_operation @property def target_temperature(self): @@ -185,14 +212,20 @@ class GenericThermostat(ClimateDevice): @property def operation_list(self): """List of available operation modes.""" - return [STATE_AUTO, STATE_OFF] + return self._operation_list def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_AUTO: + if operation_mode == STATE_HEAT: + self._current_operation = STATE_HEAT + self._enabled = True + self._async_control_heating() + elif operation_mode == STATE_COOL: + self._current_operation = STATE_COOL self._enabled = True self._async_control_heating() elif operation_mode == STATE_OFF: + self._current_operation = STATE_OFF self._enabled = False if self._is_device_active: self._heater_turn_off() @@ -252,7 +285,7 @@ class GenericThermostat(ClimateDevice): @callback def _async_keep_alive(self, time): """Call at constant intervals for keep-alive purposes.""" - if self.current_operation in [STATE_COOL, STATE_HEAT]: + if self._is_device_active: self._heater_turn_on() else: self._heater_turn_off() @@ -347,3 +380,23 @@ class GenericThermostat(ClimateDevice): data = {ATTR_ENTITY_ID: self.heater_entity_id} self.hass.async_add_job( self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data)) + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._is_away + + def turn_away_mode_on(self): + """Turn away mode on by setting it on away hold indefinitely.""" + self._is_away = True + self._saved_target_temp = self._target_temp + self._target_temp = self._away_temp + self._async_control_heating() + self.schedule_update_ha_state() + + def turn_away_mode_off(self): + """Turn away off.""" + self._is_away = False + self._target_temp = self._saved_target_temp + self._async_control_heating() + self.schedule_update_ha_state() diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 63bbce2e7c6..776e79a6827 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -29,6 +29,7 @@ from tests.common import (assert_setup_component, get_test_home_assistant, ENTITY = 'climate.test' ENT_SENSOR = 'sensor.test' ENT_SWITCH = 'switch.test' +ATTR_AWAY_MODE = 'away_mode' MIN_TEMP = 3.0 MAX_TEMP = 65.0 TARGET_TEMP = 42.0 @@ -69,22 +70,6 @@ class TestSetupClimateGenericThermostat(unittest.TestCase): }}) ) - def test_setup_with_sensor(self): - """Test set up heat_control with sensor to trigger update at init.""" - self.hass.states.set(ENT_SENSOR, 22.0, { - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS - }) - assert setup_component(self.hass, climate.DOMAIN, {'climate': { - 'platform': 'generic_thermostat', - 'name': 'test', - 'heater': ENT_SWITCH, - 'target_sensor': ENT_SENSOR - }}) - state = self.hass.states.get(ENTITY) - self.assertEqual( - TEMP_CELSIUS, state.attributes.get('unit_of_measurement')) - self.assertEqual(22.0, state.attributes.get('current_temperature')) - class TestGenericThermostatHeaterSwitching(unittest.TestCase): """Test the Generic thermostat heater switching. @@ -197,7 +182,7 @@ class TestClimateGenericThermostat(unittest.TestCase): """Test that the operation list returns the correct modes.""" state = self.hass.states.get(ENTITY) modes = state.attributes.get('operation_list') - self.assertEqual([climate.STATE_AUTO, STATE_OFF], modes) + self.assertEqual([climate.STATE_HEAT, STATE_OFF], modes) def test_set_target_temp(self): """Test the setting of the target temperature.""" @@ -210,6 +195,31 @@ class TestClimateGenericThermostat(unittest.TestCase): state = self.hass.states.get(ENTITY) self.assertEqual(30.0, state.attributes.get('temperature')) + def test_set_away_mode(self): + """Test the setting away mode.""" + climate.set_temperature(self.hass, 23) + self.hass.block_till_done() + climate.set_away_mode(self.hass, True) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(16, state.attributes.get('temperature')) + + def test_set_away_mode_and_restore_prev_temp(self): + """Test the setting and removing away mode. + + Verify original temperature is restored. + """ + climate.set_temperature(self.hass, 23) + self.hass.block_till_done() + climate.set_away_mode(self.hass, True) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(16, state.attributes.get('temperature')) + climate.set_away_mode(self.hass, False) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(23, state.attributes.get('temperature')) + def test_sensor_bad_unit(self): """Test sensor that have bad unit.""" state = self.hass.states.get(ENTITY) @@ -337,8 +347,8 @@ class TestClimateGenericThermostat(unittest.TestCase): self.hass.block_till_done() self.assertEqual(log_mock.call_count, 1) - def test_operating_mode_auto(self): - """Test change mode from OFF to AUTO. + def test_operating_mode_heat(self): + """Test change mode from OFF to HEAT. Switch turns on when temp below setpoint and mode changes. """ @@ -347,7 +357,7 @@ class TestClimateGenericThermostat(unittest.TestCase): self._setup_sensor(25) self.hass.block_till_done() self._setup_switch(False) - climate.set_operation_mode(self.hass, climate.STATE_AUTO) + climate.set_operation_mode(self.hass, climate.STATE_HEAT) self.hass.block_till_done() self.assertEqual(1, len(self.calls)) call = self.calls[0] @@ -387,6 +397,7 @@ class TestClimateGenericThermostatACMode(unittest.TestCase): 'name': 'test', 'cold_tolerance': 2, 'hot_tolerance': 4, + 'away_temp': 30, 'heater': ENT_SWITCH, 'target_sensor': ENT_SENSOR, 'ac_mode': True @@ -409,6 +420,35 @@ class TestClimateGenericThermostatACMode(unittest.TestCase): self.assertEqual(SERVICE_TURN_OFF, call.service) self.assertEqual(ENT_SWITCH, call.data['entity_id']) + def test_turn_away_mode_on_cooling(self): + """Test the setting away mode when cooling.""" + self._setup_sensor(25) + self.hass.block_till_done() + climate.set_temperature(self.hass, 19) + self.hass.block_till_done() + climate.set_away_mode(self.hass, True) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(30, state.attributes.get('temperature')) + + def test_operating_mode_cool(self): + """Test change mode from OFF to COOL. + + Switch turns on when temp below setpoint and mode changes. + """ + climate.set_operation_mode(self.hass, STATE_OFF) + climate.set_temperature(self.hass, 25) + self._setup_sensor(30) + self.hass.block_till_done() + self._setup_switch(False) + climate.set_operation_mode(self.hass, climate.STATE_COOL) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('homeassistant', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + def test_set_target_temp_ac_on(self): """Test if target temperature turn ac on.""" self._setup_switch(False) @@ -891,15 +931,13 @@ def test_custom_setup_params(hass): 'target_sensor': ENT_SENSOR, 'min_temp': MIN_TEMP, 'max_temp': MAX_TEMP, - 'target_temp': TARGET_TEMP, - 'initial_operation_mode': STATE_OFF, + 'target_temp': TARGET_TEMP }}) assert result state = hass.states.get(ENTITY) assert state.attributes.get('min_temp') == MIN_TEMP assert state.attributes.get('max_temp') == MAX_TEMP assert state.attributes.get('temperature') == TARGET_TEMP - assert state.attributes.get(climate.ATTR_OPERATION_MODE) == STATE_OFF @asyncio.coroutine @@ -907,7 +945,7 @@ def test_restore_state(hass): """Ensure states are restored on startup.""" mock_restore_cache(hass, ( State('climate.test_thermostat', '0', {ATTR_TEMPERATURE: "20", - climate.ATTR_OPERATION_MODE: "off"}), + climate.ATTR_OPERATION_MODE: "off", ATTR_AWAY_MODE: "on"}), )) hass.state = CoreState.starting @@ -927,10 +965,13 @@ def test_restore_state(hass): @asyncio.coroutine def test_no_restore_state(hass): - """Ensure states are not restored on startup if not needed.""" + """Ensure states are restored on startup if they exist. + + Allows for graceful reboot. + """ mock_restore_cache(hass, ( State('climate.test_thermostat', '0', {ATTR_TEMPERATURE: "20", - climate.ATTR_OPERATION_MODE: "off"}), + climate.ATTR_OPERATION_MODE: "off", ATTR_AWAY_MODE: "on"}), )) hass.state = CoreState.starting @@ -941,10 +982,8 @@ def test_no_restore_state(hass): 'name': 'test_thermostat', 'heater': ENT_SWITCH, 'target_sensor': ENT_SENSOR, - 'target_temp': 22, - 'initial_operation_mode': 'auto', + 'target_temp': 22 }}) state = hass.states.get('climate.test_thermostat') assert(state.attributes[ATTR_TEMPERATURE] == 22) - assert(state.attributes[climate.ATTR_OPERATION_MODE] != "off")