diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index d4b8ef16985..4fc667a5326 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -16,7 +16,8 @@ from homeassistant.components.climate import ( from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE) from homeassistant.helpers import condition -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import ( + async_track_state_change, async_track_time_interval) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -35,6 +36,7 @@ CONF_TARGET_TEMP = 'target_temp' CONF_AC_MODE = 'ac_mode' CONF_MIN_DUR = 'min_cycle_duration' CONF_TOLERANCE = 'tolerance' +CONF_KEEP_ALIVE = 'keep_alive' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -47,6 +49,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), + vol.Optional(CONF_KEEP_ALIVE): vol.All( + cv.time_period, cv.positive_timedelta), }) @@ -62,10 +66,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): ac_mode = config.get(CONF_AC_MODE) min_cycle_duration = config.get(CONF_MIN_DUR) tolerance = config.get(CONF_TOLERANCE) + keep_alive = config.get(CONF_KEEP_ALIVE) async_add_devices([GenericThermostat( hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, - target_temp, ac_mode, min_cycle_duration, tolerance)]) + target_temp, ac_mode, min_cycle_duration, tolerance, keep_alive)]) class GenericThermostat(ClimateDevice): @@ -73,7 +78,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, - tolerance): + tolerance, keep_alive): """Initialize the thermostat.""" self.hass = hass self._name = name @@ -81,6 +86,7 @@ class GenericThermostat(ClimateDevice): self.ac_mode = ac_mode self.min_cycle_duration = min_cycle_duration self._tolerance = tolerance + self._keep_alive = keep_alive self._active = False self._cur_temp = None @@ -94,6 +100,10 @@ class GenericThermostat(ClimateDevice): async_track_state_change( hass, heater_entity_id, self._async_switch_changed) + if self._keep_alive: + 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) @@ -180,6 +190,14 @@ class GenericThermostat(ClimateDevice): return self.hass.async_add_job(self.async_update_ha_state()) + @callback + def _async_keep_alive(self, time): + """Called at constant intervals for keep-alive purposes.""" + if self.current_operation in [STATE_COOL, STATE_HEAT]: + switch.async_turn_on(self.hass, self.heater_entity_id) + else: + switch.async_turn_off(self.hass, self.heater_entity_id) + @callback def _async_update_temp(self, state): """Update thermostat with latest state from sensor.""" diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 846ecdc320f..d4a5b3d21bb 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -1,9 +1,11 @@ """The tests for the generic_thermostat.""" import asyncio import datetime +import pytz import unittest from unittest import mock +import homeassistant.core as ha from homeassistant.core import callback from homeassistant.bootstrap import setup_component, async_setup_component from homeassistant.const import ( @@ -524,6 +526,187 @@ class TestClimateGenericThermostatMinCycle(unittest.TestCase): self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) +class TestClimateGenericThermostatACKeepAlive(unittest.TestCase): + """Test the Generic Thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + assert setup_component(self.hass, climate.DOMAIN, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'tolerance': 0.3, + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'ac_mode': True, + 'keep_alive': datetime.timedelta(minutes=10) + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_temp_change_ac_trigger_on_long_enough(self): + """Test if turn on signal is sent at keep-alive intervals.""" + self._setup_switch(True) + self.hass.block_till_done() + self._setup_sensor(30) + self.hass.block_till_done() + climate.set_temperature(self.hass, 25) + self.hass.block_till_done() + test_time = datetime.datetime.now(pytz.UTC) + self._send_time_changed(test_time) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=5)) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=10)) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_ac_trigger_off_long_enough(self): + """Test if turn on signal is sent at keep-alive intervals.""" + self._setup_switch(False) + self.hass.block_till_done() + self._setup_sensor(20) + self.hass.block_till_done() + climate.set_temperature(self.hass, 25) + self.hass.block_till_done() + test_time = datetime.datetime.now(pytz.UTC) + self._send_time_changed(test_time) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=5)) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=10)) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _send_time_changed(self, now): + """Send a time changed event.""" + self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + @callback + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +class TestClimateGenericThermostatKeepAlive(unittest.TestCase): + """Test the Generic Thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + assert setup_component(self.hass, climate.DOMAIN, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'tolerance': 0.3, + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'keep_alive': datetime.timedelta(minutes=10) + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_temp_change_heater_trigger_on_long_enough(self): + """Test if turn on signal is sent at keep-alive intervals.""" + self._setup_switch(True) + self.hass.block_till_done() + self._setup_sensor(20) + self.hass.block_till_done() + climate.set_temperature(self.hass, 25) + self.hass.block_till_done() + test_time = datetime.datetime.now(pytz.UTC) + self._send_time_changed(test_time) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=5)) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=10)) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_heater_trigger_off_long_enough(self): + """Test if turn on signal is sent at keep-alive intervals.""" + self._setup_switch(False) + self.hass.block_till_done() + self._setup_sensor(30) + self.hass.block_till_done() + climate.set_temperature(self.hass, 25) + self.hass.block_till_done() + test_time = datetime.datetime.now(pytz.UTC) + self._send_time_changed(test_time) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=5)) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=10)) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _send_time_changed(self, now): + """Send a time changed event.""" + self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + @callback + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + @asyncio.coroutine def test_custom_setup_params(hass): """Test the setup with custom parameters."""