From 3d972abdabb087b7384cc8ef73f4d109629abd54 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 22 Oct 2015 22:04:37 -0700 Subject: [PATCH 1/2] Clean up the heat control thermostat --- .../components/thermostat/__init__.py | 5 +- .../components/thermostat/heat_control.py | 257 +++++++----------- homeassistant/helpers/temperature.py | 2 +- tests/components/thermostat/__init__.py | 0 .../thermostat/test_heat_control.py | 115 ++++++++ 5 files changed, 216 insertions(+), 163 deletions(-) create mode 100644 tests/components/thermostat/__init__.py create mode 100644 tests/components/thermostat/test_heat_control.py diff --git a/homeassistant/components/thermostat/__init__.py b/homeassistant/components/thermostat/__init__.py index b021ec86c35..89f00da3318 100644 --- a/homeassistant/components/thermostat/__init__.py +++ b/homeassistant/components/thermostat/__init__.py @@ -14,7 +14,8 @@ import homeassistant.util as util from homeassistant.helpers.entity import Entity from homeassistant.helpers.temperature import convert from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, TEMP_CELCIUS) + ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN, + TEMP_CELCIUS) DOMAIN = "thermostat" DEPENDENCIES = [] @@ -125,7 +126,7 @@ class ThermostatDevice(Entity): @property def state(self): """ Returns the current state. """ - return self.target_temperature + return self.target_temperature or STATE_UNKNOWN @property def device_state_attributes(self): diff --git a/homeassistant/components/thermostat/heat_control.py b/homeassistant/components/thermostat/heat_control.py index c1dab1173d7..b87ca327b55 100644 --- a/homeassistant/components/thermostat/heat_control.py +++ b/homeassistant/components/thermostat/heat_control.py @@ -1,216 +1,153 @@ """ homeassistant.components.thermostat.heat_control ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Adds support for a thermostat. -Specify a start time, end time and a target temperature. -If the the current temperature is lower than the target temperature, -and the time is between start time and end time, the heater will -be turned on. Opposite if the the temperature is higher than the -target temperature the heater will be turned off. - -If away mode is activated the target temperature is sat to a min -temperature (min_temp in config). The min temperature is also used -as target temperature when no other temperature is specified. - -If the heater is manually turned on, the target temperature will -be sat to 100*C. Meaning the thermostat probably will never turn -off the heater. -If the heater is manually turned off, the target temperature will -be sat according to normal rules. (Based on target temperature -for given time intervals and the min temperature.) - -A target temperature sat with the set_temperature function will -override all other rules for the target temperature. - -Config: - -[thermostat] -platform=heat_control - -name = Name of thermostat - -heater = entity_id for heater switch, - must be a toggle device - -target_sensor = entity_id for temperature sensor, - target_sensor.state must be temperature - -time_temp = start_time-end_time:target_temp, - -min_temp = minimum temperature, used when away mode is - active or no other temperature specified. - -Example: -[thermostat] -platform=heat_control -name = Stue -heater = switch.Ovn_stue -target_sensor = tellstick_sensor.Stue_temperature -time_temp = 0700-0745:17,1500-1850:20 -min_temp = 10 - -For the example the heater will turn on at 0700 if the temperature -is lower than 17*C away mode is false. Between 0700 and 0745 the -target temperature will be 17*C. Between 0745 and 1500 no temperature -is specified. so the min_temp of 10*C will be used. From 1500 to 1850 -the target temperature is 20*, but if away mode is true the target -temperature will be sat to 10*C +Thermostat based on a sensor and a switch connected to a heater. """ import logging -import datetime -import homeassistant.components as core import homeassistant.util as util -from homeassistant.components.thermostat import ThermostatDevice +from homeassistant.components import switch +from homeassistant.components.thermostat import (ThermostatDevice, STATE_IDLE, + STATE_HEAT) from homeassistant.helpers.event import track_state_change -from homeassistant.const import TEMP_CELCIUS, STATE_ON, STATE_OFF +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELCIUS, TEMP_FAHRENHEIT) + +DEPENDENCIES = ['switch', 'sensor'] TOL_TEMP = 0.3 +CONF_NAME = 'name' +DEFAULT_NAME = 'Heat Control' +CONF_HEATER = 'heater' +CONF_SENSOR = 'target_sensor' + +_LOGGER = logging.getLogger(__name__) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """ Sets up the heat control thermostat. """ - logger = logging.getLogger(__name__) + name = config.get(CONF_NAME, DEFAULT_NAME) + heater_entity_id = config.get(CONF_HEATER) + sensor_entity_id = config.get(CONF_SENSOR) - add_devices([HeatControl(hass, config, logger)]) + if None in (heater_entity_id, sensor_entity_id): + _LOGGER.error('Missing required key %s or %s', CONF_HEATER, + CONF_SENSOR) + return False + + add_devices([HeatControl(hass, name, heater_entity_id, sensor_entity_id)]) # pylint: disable=too-many-instance-attributes class HeatControl(ThermostatDevice): """ Represents a HeatControl device. """ - def __init__(self, hass, config, logger): - - self.logger = logger + def __init__(self, hass, name, heater_entity_id, sensor_entity_id): self.hass = hass - self.heater_entity_id = config.get("heater") + self._name = name + self.heater_entity_id = heater_entity_id - self.name_device = config.get("name") - self.target_sensor_entity_id = config.get("target_sensor") + self._active = False + self._cur_temp = None + self._target_temp = None + self._unit = None - self.time_temp = [] - if config.get("time_temp"): - for time_temp in list(config.get("time_temp").split(",")): - time, temp = time_temp.split(':') - time_start, time_end = time.split('-') - start_time = datetime.datetime.time( - datetime.datetime.strptime(time_start, '%H%M')) - end_time = datetime.datetime.time( - datetime.datetime.strptime(time_end, '%H%M')) - self.time_temp.append((start_time, end_time, float(temp))) + track_state_change(hass, sensor_entity_id, self._sensor_changed) - self._min_temp = util.convert(config.get("min_temp"), float, 0) - self._max_temp = util.convert(config.get("max_temp"), float, 100) + sensor_state = hass.states.get(sensor_entity_id) + if sensor_state: + self._update_temp(sensor_state) - self._manual_sat_temp = None - self._away = False - self._heater_manual_changed = True - - track_state_change(hass, self.heater_entity_id, - self._heater_turned_on, - STATE_OFF, STATE_ON) - track_state_change(hass, self.heater_entity_id, - self._heater_turned_off, - STATE_ON, STATE_OFF) + @property + def should_poll(self): + return False @property def name(self): """ Returns the name. """ - return self.name_device + return self._name @property def unit_of_measurement(self): """ Returns the unit of measurement. """ - return TEMP_CELCIUS + return self._unit @property def current_temperature(self): - """ Returns the current temperature. """ - target_sensor = self.hass.states.get(self.target_sensor_entity_id) - if target_sensor: - return float(target_sensor.state) - else: - return None + return self._cur_temp + + @property + def operation(self): + """ Returns current operation ie. heat, cool, idle """ + return STATE_HEAT if self._active and self._is_heating else STATE_IDLE @property def target_temperature(self): """ Returns the temperature we try to reach. """ - if self._manual_sat_temp: - return self._manual_sat_temp - elif self._away: - return self.min_temp - else: - now = datetime.datetime.time(datetime.datetime.now()) - for (start_time, end_time, temp) in self.time_temp: - if start_time < now and end_time > now: - return temp - return self.min_temp + return self._target_temp def set_temperature(self, temperature): """ Set new target temperature. """ - if temperature is None: - self._manual_sat_temp = None - else: - self._manual_sat_temp = float(temperature) + self._target_temp = temperature + self._control_heating() + self.update_ha_state() - def update(self): - """ Update current thermostat. """ - heater = self.hass.states.get(self.heater_entity_id) - if heater is None: - self.logger.error("No heater available") + def _sensor_changed(self, entity_id, old_state, new_state): + """ Called when temperature changes. """ + if new_state is None: return - current_temperature = self.current_temperature - if current_temperature is None: - self.logger.error("No temperature available") + self._update_temp(new_state) + self._control_heating() + self.update_ha_state() + + def _update_temp(self, state): + """ Update thermostat with latest state from sensor. """ + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + + if unit not in (TEMP_CELCIUS, TEMP_FAHRENHEIT): + self._cur_temp = None + self._unit = None + _LOGGER.error('Sensor has unsupported unit: %s (allowed: %s, %s)', + unit, TEMP_CELCIUS, TEMP_FAHRENHEIT) return - if (current_temperature - self.target_temperature) > \ - TOL_TEMP and heater.state is STATE_ON: - self._heater_manual_changed = False - core.turn_off(self.hass, self.heater_entity_id) - elif (self.target_temperature - self.current_temperature) > TOL_TEMP \ - and heater.state is STATE_OFF: - self._heater_manual_changed = False - core.turn_on(self.hass, self.heater_entity_id) + temp = util.convert(state.state, float) - def _heater_turned_on(self, entity_id, old_state, new_state): - """ Heater is turned on. """ - if not self._heater_manual_changed: - pass - else: - self.set_temperature(self.max_temp) + if temp is None: + self._cur_temp = None + self._unit = None + _LOGGER.error('Unable to parse sensor temperature: %s', + state.state) + return - self._heater_manual_changed = True + self._cur_temp = temp + self._unit = unit - def _heater_turned_off(self, entity_id, old_state, new_state): - """ Heater is turned off. """ - if self._heater_manual_changed: - self.set_temperature(None) + def _control_heating(self): + """ Check if we need to turn heating on or off. """ + if not self._active and None not in (self._cur_temp, + self._target_temp): + self._active = True + _LOGGER.info('Obtained current and target temperature. ' + 'Heat control active.') + + if not self._active: + return + + too_cold = self._target_temp - self._cur_temp > TOL_TEMP + is_heating = self._is_heating + + if too_cold and not is_heating: + _LOGGER.info('Turning on heater %s', self.heater_entity_id) + switch.turn_on(self.hass, self.heater_entity_id) + elif not too_cold and is_heating: + _LOGGER.info('Turning off heater %s', self.heater_entity_id) + switch.turn_off(self.hass, self.heater_entity_id) @property - def is_away_mode_on(self): - """ - Returns if away mode is on. - """ - return self._away - - def turn_away_mode_on(self): - """ Turns away mode on. """ - self._away = True - - def turn_away_mode_off(self): - """ Turns away mode off. """ - self._away = False - - @property - def min_temp(self): - """ Return minimum temperature. """ - return self._min_temp - - @property - def max_temp(self): - """ Return maxmum temperature. """ - return self._max_temp + def _is_heating(self): + return switch.is_on(self.hass, self.heater_entity_id) diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py index eaf1f78d927..4a867ab2f2e 100644 --- a/homeassistant/helpers/temperature.py +++ b/homeassistant/helpers/temperature.py @@ -11,7 +11,7 @@ import homeassistant.util.temperature as temp_util def convert(temperature, unit, to_unit): """ Converts temperature to correct unit. """ - if unit == to_unit: + if unit == to_unit or unit is None or to_unit is None: return temperature elif unit == TEMP_CELCIUS: return temp_util.celcius_to_fahrenheit(temperature) diff --git a/tests/components/thermostat/__init__.py b/tests/components/thermostat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/thermostat/test_heat_control.py b/tests/components/thermostat/test_heat_control.py new file mode 100644 index 00000000000..f0b487ae86c --- /dev/null +++ b/tests/components/thermostat/test_heat_control.py @@ -0,0 +1,115 @@ +""" +tests.components.thermostat.test_heat_control +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests heat control thermostat. +""" +import unittest + +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + STATE_OFF, + TEMP_CELCIUS, +) +import homeassistant.core as ha +from homeassistant.components import switch, thermostat + + +entity = 'thermostat.test' +ent_sensor = 'sensor.test' +ent_switch = 'switch.test' + + +class TestThermostatHeatControl(unittest.TestCase): + """ Test the Heat Control thermostat. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = ha.HomeAssistant() + self.hass.config.temperature_unit = TEMP_CELCIUS + thermostat.setup(self.hass, {'thermostat': { + 'platform': 'heat_control', + 'name': 'test', + 'heater': ent_switch, + 'target_sensor': ent_sensor + }}) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_setup_defaults_to_unknown(self): + self.assertEqual('unknown', self.hass.states.get(entity).state) + + def test_set_target_temp(self): + thermostat.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self.assertEqual('30.0', self.hass.states.get(entity).state) + + def test_set_target_temp_turns_on_heater(self): + self._setup_switch(False) + self._setup_sensor(25) + self.hass.pool.block_till_done() + thermostat.set_temperature(self.hass, 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_set_target_temp_turns_off_heater(self): + self._setup_switch(True) + self._setup_sensor(30) + self.hass.pool.block_till_done() + thermostat.set_temperature(self.hass, 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 test_set_temp_change_turns_on_heater(self): + 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_turns_off_heater(self): + 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_CELCIUS): + """ 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) From c2d75efb4de608b315d77d1c62ab01bbf6baf3d6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 22 Oct 2015 22:14:40 -0700 Subject: [PATCH 2/2] Add missing docstring to heat control thermo --- homeassistant/components/thermostat/heat_control.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/thermostat/heat_control.py b/homeassistant/components/thermostat/heat_control.py index b87ca327b55..bfec48fea3d 100644 --- a/homeassistant/components/thermostat/heat_control.py +++ b/homeassistant/components/thermostat/heat_control.py @@ -150,4 +150,5 @@ class HeatControl(ThermostatDevice): @property def _is_heating(self): + """ If the heater is currently heating. """ return switch.is_on(self.hass, self.heater_entity_id)