From 496972a5872f42be61b4638c2652ecc85040e5a4 Mon Sep 17 00:00:00 2001 From: Matthew Treinish Date: Wed, 3 Aug 2016 18:26:46 -0400 Subject: [PATCH] Add option to heat_control component to set min cycle duration This commit adds a new config option to the heat_control thermostat component, min_cycle_duration. Some heaters and/or ACs don't like being constantly cycled on and off. Prior to this patch the heat_control component can end up cycling the switch quite frequently. (depending on how quickly the temperature changes) The new option added is used for setting a minimum duration that must have elapsed in either the on or off state before the thermostat will send the service call to cycle the switch. This should enable users to hand tune how frequently heat_control can switch the device on or off to best suit the device being used. --- .../components/thermostat/heat_control.py | 24 ++- .../thermostat/test_heat_control.py | 184 ++++++++++++++++++ 2 files changed, 205 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermostat/heat_control.py b/homeassistant/components/thermostat/heat_control.py index 831f1dd41b9..1c4d830b9fd 100644 --- a/homeassistant/components/thermostat/heat_control.py +++ b/homeassistant/components/thermostat/heat_control.py @@ -8,12 +8,14 @@ import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import condition import homeassistant.util as util from homeassistant.components import switch from homeassistant.components.thermostat import ( STATE_HEAT, STATE_COOL, STATE_IDLE, ThermostatDevice) from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT, + STATE_ON, STATE_OFF) from homeassistant.helpers.event import track_state_change DEPENDENCIES = ['switch', 'sensor'] @@ -28,6 +30,7 @@ CONF_MIN_TEMP = 'min_temp' CONF_MAX_TEMP = 'max_temp' CONF_TARGET_TEMP = 'target_temp' CONF_AC_MODE = 'ac_mode' +CONF_MIN_DUR = 'min_cycle_duration' _LOGGER = logging.getLogger(__name__) @@ -40,6 +43,7 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), vol.Optional(CONF_AC_MODE): vol.Coerce(bool), + vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta), }) @@ -52,9 +56,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): max_temp = config.get(CONF_MAX_TEMP) target_temp = config.get(CONF_TARGET_TEMP) ac_mode = config.get(CONF_AC_MODE) + min_cycle_duration = config.get(CONF_MIN_DUR) add_devices([HeatControl(hass, name, heater_entity_id, sensor_entity_id, - min_temp, max_temp, target_temp, ac_mode)]) + min_temp, max_temp, target_temp, ac_mode, + min_cycle_duration)]) # pylint: disable=too-many-instance-attributes, abstract-method @@ -63,12 +69,13 @@ class HeatControl(ThermostatDevice): # pylint: disable=too-many-arguments def __init__(self, hass, name, heater_entity_id, sensor_entity_id, - min_temp, max_temp, target_temp, ac_mode): + min_temp, max_temp, target_temp, ac_mode, min_cycle_duration): """Initialize the thermostat.""" self.hass = hass self._name = name self.heater_entity_id = heater_entity_id self.ac_mode = ac_mode + self.min_cycle_duration = min_cycle_duration self._active = False self._cur_temp = None @@ -187,6 +194,17 @@ class HeatControl(ThermostatDevice): if not self._active: return + if self.min_cycle_duration: + if self._is_device_active: + current_state = STATE_ON + else: + current_state = STATE_OFF + long_enough = condition.state(self.hass, self.heater_entity_id, + current_state, + self.min_cycle_duration) + if not long_enough: + return + if self.ac_mode: too_hot = self._cur_temp - self._target_temp > TOL_TEMP is_cooling = self._is_device_active diff --git a/tests/components/thermostat/test_heat_control.py b/tests/components/thermostat/test_heat_control.py index 8ab571ce56b..bb0d9afb8a0 100644 --- a/tests/components/thermostat/test_heat_control.py +++ b/tests/components/thermostat/test_heat_control.py @@ -1,5 +1,8 @@ """The tests for the heat control thermostat.""" +import datetime import unittest +from unittest import mock + from homeassistant.bootstrap import _setup_component from homeassistant.const import ( @@ -296,3 +299,184 @@ class TestThermostatHeatControlACMode(unittest.TestCase): self.hass.services.register('switch', SERVICE_TURN_ON, log_call) self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +class TestThermostatHeatControlACModeMinCycle(unittest.TestCase): + """Test the Heat Control 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 + thermostat.setup(self.hass, {'thermostat': { + 'platform': 'heat_control', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'ac_mode': True, + 'min_cycle_duration': 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_not_long_enough(self): + """Test if temperature change turn ac on.""" + self._setup_switch(False) + thermostat.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_ac_trigger_on_long_enough(self): + """Test if temperature change turn ac on.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(False) + thermostat.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.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_not_long_enough(self): + """Test if temperature change turn ac on.""" + self._setup_switch(True) + thermostat.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_ac_trigger_off_long_enough(self): + """Test if temperature change turn ac on.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(True) + thermostat.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.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 _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 = [] + + 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 TestThermostatHeatControlMinCycle(unittest.TestCase): + """Test the Heat Control 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 + thermostat.setup(self.hass, {'thermostat': { + 'platform': 'heat_control', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'min_cycle_duration': 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_off_not_long_enough(self): + """Test if temp change doesn't turn heater off because of time.""" + self._setup_switch(True) + thermostat.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_heater_trigger_on_not_long_enough(self): + """Test if temp change doesn't turn heater on because of time.""" + self._setup_switch(False) + thermostat.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_heater_trigger_on_long_enough(self): + """Test if temperature change turn heater on after min cycle.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(False) + thermostat.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.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 temperature change turn heater off after min cycle.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(True) + thermostat.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.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 _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 = [] + + 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)