""" Adds support for generic thermostat units. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.generic_thermostat/ """ import asyncio import logging 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, STATE_AUTO, ClimateDevice, ATTR_OPERATION_MODE, ATTR_AWAY_MODE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE, SUPPORT_TARGET_TEMPERATURE, PLATFORM_SCHEMA) from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_UNKNOWN, PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE) from homeassistant.helpers import condition from homeassistant.helpers.event import ( async_track_state_change, async_track_time_interval) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['switch', 'sensor'] DEFAULT_TOLERANCE = 0.3 DEFAULT_NAME = 'Generic Thermostat' CONF_HEATER = 'heater' CONF_SENSOR = 'target_sensor' 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' CONF_COLD_TOLERANCE = 'cold_tolerance' CONF_HOT_TOLERANCE = 'hot_tolerance' CONF_KEEP_ALIVE = 'keep_alive' CONF_INITIAL_OPERATION_MODE = 'initial_operation_mode' CONF_AWAY_TEMP = 'away_temp' CONF_PRECISION = 'precision' SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HEATER): cv.entity_id, vol.Required(CONF_SENSOR): cv.entity_id, vol.Optional(CONF_AC_MODE): cv.boolean, vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce( float), vol.Optional(CONF_HOT_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), vol.Optional(CONF_INITIAL_OPERATION_MODE): vol.In([STATE_AUTO, STATE_OFF]), vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float), vol.Optional(CONF_PRECISION): vol.In( [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]), }) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the generic thermostat platform.""" name = config.get(CONF_NAME) heater_entity_id = config.get(CONF_HEATER) sensor_entity_id = config.get(CONF_SENSOR) min_temp = config.get(CONF_MIN_TEMP) 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) cold_tolerance = config.get(CONF_COLD_TOLERANCE) 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) precision = config.get(CONF_PRECISION) async_add_entities([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, away_temp, precision)]) class GenericThermostat(ClimateDevice): """Representation of a Generic Thermostat device.""" 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, away_temp, precision): """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._cold_tolerance = cold_tolerance 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 self._temp_precision = precision 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 self._current_operation = STATE_OFF else: self._enabled = True self._active = False self._cur_temp = None self._temp_lock = asyncio.Lock() self._min_temp = min_temp self._max_temp = max_temp self._target_temp = target_temp self._unit = hass.config.units.temperature_unit self._support_flags = SUPPORT_FLAGS if away_temp is not None: self._support_flags = SUPPORT_FLAGS | SUPPORT_AWAY_MODE self._away_temp = away_temp self._is_away = False async_track_state_change( hass, sensor_entity_id, self._async_sensor_changed) async_track_state_change( hass, heater_entity_id, self._async_switch_changed) if self._keep_alive: async_track_time_interval( hass, self._async_control_heating, self._keep_alive) sensor_state = hass.states.get(sensor_entity_id) if sensor_state and sensor_state.state != STATE_UNKNOWN: self._async_update_temp(sensor_state) async def async_added_to_hass(self): """Run when entity about to be added.""" # Check If we have an old state old_state = await async_get_last_state(self.hass, self.entity_id) if old_state is not None: # If we have no initial temperature, restore if self._target_temp is None: # If we have a previously saved temperature if old_state.attributes.get(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]) if old_state.attributes.get(ATTR_AWAY_MODE) is not None: self._is_away = str( old_state.attributes[ATTR_AWAY_MODE]) == STATE_ON if (self._initial_operation_mode is None and old_state.attributes[ATTR_OPERATION_MODE] is not None): self._current_operation = \ old_state.attributes[ATTR_OPERATION_MODE] self._enabled = self._current_operation != STATE_OFF else: # No previous state, try and restore defaults if self._target_temp is None: if self.ac_mode: self._target_temp = self.max_temp else: self._target_temp = self.min_temp _LOGGER.warning("No previously saved temperature, setting to %s", self._target_temp) @property def state(self): """Return the current state.""" if self._is_device_active: return self.current_operation if self._enabled: return STATE_IDLE return STATE_OFF @property def should_poll(self): """Return the polling state.""" return False @property def name(self): """Return the name of the thermostat.""" return self._name @property def precision(self): """Return the precision of the system.""" if self._temp_precision is not None: return self._temp_precision return super().precision @property def temperature_unit(self): """Return the unit of measurement.""" return self._unit @property def current_temperature(self): """Return the sensor temperature.""" return self._cur_temp @property def current_operation(self): """Return current operation.""" return self._current_operation @property def target_temperature(self): """Return the temperature we try to reach.""" return self._target_temp @property def operation_list(self): """List of available operation modes.""" return self._operation_list async def async_set_operation_mode(self, operation_mode): """Set operation mode.""" if operation_mode == STATE_HEAT: self._current_operation = STATE_HEAT self._enabled = True await self._async_control_heating(force=True) elif operation_mode == STATE_COOL: self._current_operation = STATE_COOL self._enabled = True await self._async_control_heating(force=True) elif operation_mode == STATE_OFF: self._current_operation = STATE_OFF self._enabled = False if self._is_device_active: await self._async_heater_turn_off() else: _LOGGER.error("Unrecognized operation mode: %s", operation_mode) return # Ensure we update the current operation after changing the mode self.schedule_update_ha_state() async def async_turn_on(self): """Turn thermostat on.""" await self.async_set_operation_mode(self.operation_list[0]) async def async_turn_off(self): """Turn thermostat off.""" await self.async_set_operation_mode(STATE_OFF) async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return self._target_temp = temperature await self._async_control_heating(force=True) await self.async_update_ha_state() @property def min_temp(self): """Return the minimum temperature.""" if self._min_temp: return self._min_temp # get default temp from super class return super().min_temp @property def max_temp(self): """Return the maximum temperature.""" if self._max_temp: return self._max_temp # Get default temp from super class return super().max_temp async def _async_sensor_changed(self, entity_id, old_state, new_state): """Handle temperature changes.""" if new_state is None: return self._async_update_temp(new_state) await self._async_control_heating() await self.async_update_ha_state() @callback def _async_switch_changed(self, entity_id, old_state, new_state): """Handle heater switch state changes.""" if new_state is None: return self.async_schedule_update_ha_state() @callback def _async_update_temp(self, state): """Update thermostat with latest state from sensor.""" try: self._cur_temp = float(state.state) except ValueError as ex: _LOGGER.error("Unable to update from sensor: %s", ex) async def _async_control_heating(self, time=None, force=False): """Check if we need to turn heating on or off.""" async with self._temp_lock: if not self._active and None not in (self._cur_temp, self._target_temp): self._active = True _LOGGER.info("Obtained current and target temperature. " "Generic thermostat active. %s, %s", self._cur_temp, self._target_temp) if not self._active or not self._enabled: return if not force and time is None: # If the `force` argument is True, we # ignore `min_cycle_duration`. # If the `time` argument is not none, we were invoked for # keep-alive purposes, and `min_cycle_duration` is irrelevant. 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 too_cold = \ self._target_temp - self._cur_temp >= self._cold_tolerance too_hot = \ self._cur_temp - self._target_temp >= self._hot_tolerance if self._is_device_active: if (self.ac_mode and too_cold) or \ (not self.ac_mode and too_hot): _LOGGER.info("Turning off heater %s", self.heater_entity_id) await self._async_heater_turn_off() elif time is not None: # The time argument is passed only in keep-alive case await self._async_heater_turn_on() else: if (self.ac_mode and too_hot) or \ (not self.ac_mode and too_cold): _LOGGER.info("Turning on heater %s", self.heater_entity_id) await self._async_heater_turn_on() elif time is not None: # The time argument is passed only in keep-alive case await self._async_heater_turn_off() @property def _is_device_active(self): """If the toggleable device is currently active.""" return self.hass.states.is_state(self.heater_entity_id, STATE_ON) @property def supported_features(self): """Return the list of supported features.""" return self._support_flags async def _async_heater_turn_on(self): """Turn heater toggleable device on.""" data = {ATTR_ENTITY_ID: self.heater_entity_id} await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data) async def _async_heater_turn_off(self): """Turn heater toggleable device off.""" data = {ATTR_ENTITY_ID: self.heater_entity_id} await 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 async def async_turn_away_mode_on(self): """Turn away mode on by setting it on away hold indefinitely.""" if self._is_away: return self._is_away = True self._saved_target_temp = self._target_temp self._target_temp = self._away_temp await self._async_control_heating(force=True) await self.async_update_ha_state() async def async_turn_away_mode_off(self): """Turn away off.""" if not self._is_away: return self._is_away = False self._target_temp = self._saved_target_temp await self._async_control_heating(force=True) await self.async_update_ha_state()