From 3436676de2bcd2dd5a7d24ffbe2991856cce8fe1 Mon Sep 17 00:00:00 2001
From: Marius <ciotlos@gmail.com>
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")
-- 
GitLab