Add keep-alive feature to the generic thermostat (#6040)

* Add keep-alive feature to the generic thermostat

* Comply with maximum line lengths

* Added tests for the keep-alive functionality
pull/6402/head
Lev Aronsky 2017-03-04 19:42:43 +02:00 committed by Paulus Schoutsen
parent aac9f972cf
commit f396a4593e
2 changed files with 204 additions and 3 deletions

View File

@ -16,7 +16,8 @@ from homeassistant.components.climate import (
from homeassistant.const import ( from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE) ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE)
from homeassistant.helpers import condition 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 import homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -35,6 +36,7 @@ CONF_TARGET_TEMP = 'target_temp'
CONF_AC_MODE = 'ac_mode' CONF_AC_MODE = 'ac_mode'
CONF_MIN_DUR = 'min_cycle_duration' CONF_MIN_DUR = 'min_cycle_duration'
CONF_TOLERANCE = 'tolerance' CONF_TOLERANCE = 'tolerance'
CONF_KEEP_ALIVE = 'keep_alive'
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), vol.Optional(CONF_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
vol.Optional(CONF_TARGET_TEMP): 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) ac_mode = config.get(CONF_AC_MODE)
min_cycle_duration = config.get(CONF_MIN_DUR) min_cycle_duration = config.get(CONF_MIN_DUR)
tolerance = config.get(CONF_TOLERANCE) tolerance = config.get(CONF_TOLERANCE)
keep_alive = config.get(CONF_KEEP_ALIVE)
async_add_devices([GenericThermostat( async_add_devices([GenericThermostat(
hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, 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): class GenericThermostat(ClimateDevice):
@ -73,7 +78,7 @@ class GenericThermostat(ClimateDevice):
def __init__(self, hass, name, heater_entity_id, sensor_entity_id, def __init__(self, hass, name, heater_entity_id, sensor_entity_id,
min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration,
tolerance): tolerance, keep_alive):
"""Initialize the thermostat.""" """Initialize the thermostat."""
self.hass = hass self.hass = hass
self._name = name self._name = name
@ -81,6 +86,7 @@ class GenericThermostat(ClimateDevice):
self.ac_mode = ac_mode self.ac_mode = ac_mode
self.min_cycle_duration = min_cycle_duration self.min_cycle_duration = min_cycle_duration
self._tolerance = tolerance self._tolerance = tolerance
self._keep_alive = keep_alive
self._active = False self._active = False
self._cur_temp = None self._cur_temp = None
@ -94,6 +100,10 @@ class GenericThermostat(ClimateDevice):
async_track_state_change( async_track_state_change(
hass, heater_entity_id, self._async_switch_changed) 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) sensor_state = hass.states.get(sensor_entity_id)
if sensor_state: if sensor_state:
self._async_update_temp(sensor_state) self._async_update_temp(sensor_state)
@ -180,6 +190,14 @@ class GenericThermostat(ClimateDevice):
return return
self.hass.async_add_job(self.async_update_ha_state()) 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 @callback
def _async_update_temp(self, state): def _async_update_temp(self, state):
"""Update thermostat with latest state from sensor.""" """Update thermostat with latest state from sensor."""

View File

@ -1,9 +1,11 @@
"""The tests for the generic_thermostat.""" """The tests for the generic_thermostat."""
import asyncio import asyncio
import datetime import datetime
import pytz
import unittest import unittest
from unittest import mock from unittest import mock
import homeassistant.core as ha
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.bootstrap import setup_component, async_setup_component from homeassistant.bootstrap import setup_component, async_setup_component
from homeassistant.const import ( from homeassistant.const import (
@ -524,6 +526,187 @@ class TestClimateGenericThermostatMinCycle(unittest.TestCase):
self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) 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 @asyncio.coroutine
def test_custom_setup_params(hass): def test_custom_setup_params(hass):
"""Test the setup with custom parameters.""" """Test the setup with custom parameters."""