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
This commit is contained in:
Marius 2018-01-04 20:05:11 +02:00 committed by Daniel Høyer Iversen
parent 04de22613c
commit 3436676de2
2 changed files with 149 additions and 57 deletions

View file

@ -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()

View file

@ -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")