diff --git a/README.md b/README.md index 1957f36..7ba48b3 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,9 @@ gains to quickly test the behavior without waiting the integral to stabilize by * **heater** (Required): entity_id for heater control, should be a toggle device or a valve accepting direct input between 0% and 100%. If a valve is used, pwm parameter should be set to 0. Becomes air conditioning switch when ac_mode is set to true. +* **cooler** (Optional): entity_id for cooling control, should be a toggle device or a valve +accepting direct input between 0% and 100%. If a valve is used, pwm parameter should be set to 0. +Becomes air conditioning switch when ac_mode is set to true. * **invert_heater** (Optional): if set to true, inverts the polarity of heater switch (switch is on while idle and off while active). Must be a boolean (defaults to false). * **target_sensor** (Required): entity_id for a temperature sensor, target_sensor.state must be @@ -251,12 +254,15 @@ temperature read by the sensor specified in the target_sensor option and the tar that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5 (float, default 0.3). -* **ac_mode** (Optional): Set the switch specified in the heater option to be treated as a cooling -device instead of a heating device. Should be a boolean (default: false). +* **ac_mode** (Optional): Set the switch specified in the heater option to be treated as a +heating/cooling device instead of a pure heating device. Should be a boolean (default: false). +* **force_off_state** (Optional): If set to true (default value), Home Assistant will force the +heater entity to OFF state when the thermostat is in OFF. Set parameter to false to control the +heater entity externally while the thermostat is OFF. * **preset_sync_mode** (Optional): If set to sync mode, manually setting a temperature will enable the corresponding preset. In example, if away temperature is set to 14°C, manually setting the temperature to 14°C on the thermostat will automatically enable the away preset mode. Should be -string either 'sync' or 'none' (default: 'none'). +a string, either 'sync' or 'none' (default: 'none'). * **boost_pid_off** (Optional): When set to true, the PID will be set to OFF state while boost preset is selected, and the thermostat will operate in hysteresis mode. This helps to quickly raise the temperature in a room for a short period of time. Should be a boolean (default: false). diff --git a/custom_components/smart_thermostat/climate.py b/custom_components/smart_thermostat/climate.py index 9d5a2fc..6a93a2c 100644 --- a/custom_components/smart_thermostat/climate.py +++ b/custom_components/smart_thermostat/climate.py @@ -32,6 +32,7 @@ SERVICE_SET_VALUE, DOMAIN as NUMBER_DOMAIN ) +from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.core import DOMAIN as HA_DOMAIN, CoreState, callback from homeassistant.util import slugify import homeassistant.helpers.config_validation as cv @@ -67,10 +68,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(const.CONF_HEATER): cv.entity_id, + vol.Optional(const.CONF_COOLER): cv.entity_id, vol.Required(const.CONF_INVERT_HEATER, default=False): cv.boolean, vol.Required(const.CONF_SENSOR): cv.entity_id, vol.Optional(const.CONF_OUTDOOR_SENSOR): cv.entity_id, vol.Optional(const.CONF_AC_MODE): cv.boolean, + vol.Optional(const.CONF_FORCE_OFF_STATE, default=True): cv.boolean, vol.Optional(const.CONF_MAX_TEMP): vol.Coerce(float), vol.Optional(const.CONF_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_NAME, default=const.DEFAULT_NAME): cv.string, @@ -142,6 +145,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= 'name': config.get(CONF_NAME), 'unique_id': config.get(CONF_UNIQUE_ID), 'heater_entity_id': config.get(const.CONF_HEATER), + 'cooler_entity_id': config.get(const.CONF_COOLER), 'invert_heater': config.get(const.CONF_INVERT_HEATER), 'sensor_entity_id': config.get(const.CONF_SENSOR), 'ext_sensor_entity_id': config.get(const.CONF_OUTDOOR_SENSOR), @@ -151,6 +155,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= 'hot_tolerance': config.get(const.CONF_HOT_TOLERANCE), 'cold_tolerance': config.get(const.CONF_COLD_TOLERANCE), 'ac_mode': config.get(const.CONF_AC_MODE), + 'force_off_state': config.get(const.CONF_FORCE_OFF_STATE), 'min_cycle_duration': config.get(const.CONF_MIN_CYCLE_DURATION), 'min_off_cycle_duration': config.get(const.CONF_MIN_OFF_CYCLE_DURATION), 'min_cycle_duration_pid_off': config.get(const.CONF_MIN_CYCLE_DURATION_PID_OFF), @@ -239,12 +244,14 @@ def __init__(self, **kwargs): self._name = kwargs.get('name') self._unique_id = kwargs.get('unique_id') self._heater_entity_id = kwargs.get('heater_entity_id') + self._cooler_entity_id = kwargs.get('cooler_entity_id', None) self._heater_polarity_invert = kwargs.get('invert_heater') self._sensor_entity_id = kwargs.get('sensor_entity_id') self._ext_sensor_entity_id = kwargs.get('ext_sensor_entity_id') if self._unique_id == 'none': self._unique_id = slugify(f"{DOMAIN}_{self._name}_{self._heater_entity_id}") - self._ac_mode = kwargs.get('ac_mode') + self._ac_mode = kwargs.get('ac_mode', False) + self._force_off_state = kwargs.get('force_off_state', True) self._keep_alive = kwargs.get('keep_alive') self._sampling_period = kwargs.get('sampling_period').seconds self._sensor_stall = kwargs.get('sensor_stall').seconds @@ -298,7 +305,7 @@ def __init__(self, **kwargs): ClimateEntityFeature.PRESET_MODE self._difference = kwargs.get('difference') if self._ac_mode: - self._attr_hvac_modes = [HVACMode.COOL, HVACMode.OFF] + self._attr_hvac_modes = [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] self._min_out = -self._difference self._max_out = 0 else: @@ -372,6 +379,12 @@ async def async_added_to_hass(self): self.hass, self._heater_entity_id, self._async_switch_changed)) + if self._cooler_entity_id is not None: + self.async_on_remove( + async_track_state_change( + self.hass, + self._cooler_entity_id, + self._async_switch_changed)) if self._keep_alive: self.async_on_remove( async_track_time_interval( @@ -420,7 +433,7 @@ def _async_startup(*_): self._i = float(old_state.attributes.get('pid_i')) self._pid_controller.integral = self._i if not self._hvac_mode and old_state.state: - self._hvac_mode = old_state.state + self.set_hvac_mode(old_state.state) if old_state.attributes.get('kp') is not None and self._pid_controller is not None: self._kp = float(old_state.attributes.get('kp')) self._pid_controller.set_pid_param(kp=self._kp) @@ -479,6 +492,9 @@ def unique_id(self): """Return a unique ID.""" return self._unique_id + def _get_number_entity_domain(self, entity_id): + return INPUT_NUMBER_DOMAIN if "input_number" in entity_id else NUMBER_DOMAIN + @property def precision(self): """Return the precision of the system.""" @@ -515,7 +531,7 @@ def hvac_action(self): return HVACAction.OFF if not self._is_device_active: return HVACAction.IDLE - if self._ac_mode: + elif self._hvac_mode == HVACMode.COOL: return HVACAction.COOLING return HVACAction.HEATING @@ -661,17 +677,46 @@ def extra_state_attributes(self): }) return device_state_attributes + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.HEAT: + self._min_out = 0 + self._max_out = self._difference + self._hvac_mode = HVACMode.HEAT + elif hvac_mode == HVACMode.COOL: + self._min_out = -self._difference + self._max_out = 0 + self._hvac_mode = HVACMode.COOL + elif hvac_mode == HVACMode.HEAT_COOL: + self._min_out = -self._difference + self._max_out = self._difference + self._hvac_mode = HVACMode.HEAT_COOL + elif hvac_mode == HVACMode.OFF: + self._hvac_mode = HVACMode.OFF + self._control_output = 0 + self._previous_temp = None + self._previous_temp_time = None + if self._pid_controller is not None: + self._pid_controller.clear_samples() + if self._pid_controller: + self._pid_controller.out_max = self._max_out + self._pid_controller.out_min = self._min_out + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" + await self._async_heater_turn_off(force=True) if hvac_mode == HVACMode.HEAT: + self._min_out = 0 + self._max_out = self._difference self._hvac_mode = HVACMode.HEAT - await self._async_control_heating(calc_pid=True) elif hvac_mode == HVACMode.COOL: + self._min_out = -self._difference + self._max_out = 0 self._hvac_mode = HVACMode.COOL - await self._async_control_heating(calc_pid=True) elif hvac_mode == HVACMode.HEAT_COOL: + self._min_out = -self._difference + self._max_out = self._difference self._hvac_mode = HVACMode.HEAT_COOL - await self._async_control_heating(calc_pid=True) elif hvac_mode == HVACMode.OFF: self._hvac_mode = HVACMode.OFF self._control_output = 0 @@ -684,7 +729,19 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: ATTR_VALUE: self._control_output} _LOGGER.debug("%s: Set heater to %s from async_set_hvac_mode(%s)", self.entity_id, self._control_output, hvac_mode) - await self.hass.services.async_call(NUMBER_DOMAIN, SERVICE_SET_VALUE, data) + await self.hass.services.async_call( + self._get_number_entity_domain(self._heater_entity_id), + SERVICE_SET_VALUE, + data) + if self._cooler_entity_id is not None: + data = {ATTR_ENTITY_ID: self._cooler_entity_id, + ATTR_VALUE: self._control_output} + _LOGGER.debug("%s: Set cooler to %s from async_set_hvac_mode(%s)", self.entity_id, + self._control_output, hvac_mode) + await self.hass.services.async_call( + self._get_number_entity_domain(self._cooler_entity_id), + SERVICE_SET_VALUE, + data) # Clear the samples to avoid integrating the off period self._previous_temp = None self._previous_temp_time = None @@ -693,6 +750,11 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: else: _LOGGER.error("%s: Unrecognized HVAC mode: %s", self.entity_id, hvac_mode) return + if self._pid_controller: + self._pid_controller.out_max = self._max_out + self._pid_controller.out_min = self._min_out + if self._hvac_mode != HVACMode.OFF: + await self._async_control_heating(calc_pid=True) # Ensure we update the current operation after changing the mode self.async_write_ha_state() @@ -867,16 +929,27 @@ async def _async_control_heating(self, time_func=None, calc_pid=False): "Thermostat.", self.entity_id, self._current_temp, self._target_temp) if not self._active or self._hvac_mode == HVACMode.OFF: - if self._hvac_mode == HVACMode.OFF and self._is_device_active: + if self._force_off_state and self._hvac_mode == HVACMode.OFF and \ + self._is_device_active: _LOGGER.debug("%s: %s is active while HVAC mode is %s. Turning it OFF.", - self.entity_id, self._heater_entity_id, self._hvac_mode) + self.entity_id, self.heater_or_cooler_entity, self._hvac_mode) if self._pwm: await self._async_heater_turn_off(force=True) else: self._control_output = 0 data = {ATTR_ENTITY_ID: self._heater_entity_id, ATTR_VALUE: self._control_output} - await self.hass.services.async_call(NUMBER_DOMAIN, SERVICE_SET_VALUE, data) + await self.hass.services.async_call( + self._get_number_entity_domain(self._heater_entity_id), + SERVICE_SET_VALUE, + data) + if self._cooler_entity_id is not None: + data = {ATTR_ENTITY_ID: self._cooler_entity_id, + ATTR_VALUE: self._control_output} + await self.hass.services.async_call( + self._get_number_entity_domain(self._cooler_entity_id), + SERVICE_SET_VALUE, + data) self.async_write_ha_state() return @@ -893,19 +966,26 @@ async def _async_control_heating(self, time_func=None, calc_pid=False): def _is_device_active(self): """If the toggleable device is currently active.""" if self._heater_polarity_invert: - return self.hass.states.is_state(self._heater_entity_id, STATE_OFF) - return self.hass.states.is_state(self._heater_entity_id, STATE_ON) + return self.hass.states.is_state(self.heater_or_cooler_entity, STATE_OFF) + return self.hass.states.is_state(self.heater_or_cooler_entity, STATE_ON) @property def supported_features(self): """Return the list of supported features.""" return self._support_flags + @property + def heater_or_cooler_entity(self): + """Return the entity to be controlled based on HVAC MODE""" + if self.hvac_mode == HVACMode.COOL and self._cooler_entity_id is not None: + return self._cooler_entity_id + return self._heater_entity_id + async def _async_heater_turn_on(self): """Turn heater toggleable device on.""" if time.time() - self._last_heat_cycle_time >= self._min_off_cycle_duration.seconds: - data = {ATTR_ENTITY_ID: self._heater_entity_id} - _LOGGER.info("%s: Turning ON %s", self.entity_id, self._heater_entity_id) + data = {ATTR_ENTITY_ID: self.heater_or_cooler_entity} + _LOGGER.info("%s: Turning ON %s", self.entity_id, self.heater_or_cooler_entity) if self._heater_polarity_invert: service = SERVICE_TURN_OFF else: @@ -914,22 +994,32 @@ async def _async_heater_turn_on(self): self._last_heat_cycle_time = time.time() else: _LOGGER.info("%s: Reject request turning ON %s: Cycle is too short", - self.entity_id, self._heater_entity_id) + self.entity_id, self.heater_or_cooler_entity) async def _async_heater_turn_off(self, force=False): """Turn heater toggleable device off.""" - if time.time() - self._last_heat_cycle_time >= self._min_on_cycle_duration.seconds or force: - data = {ATTR_ENTITY_ID: self._heater_entity_id} - _LOGGER.info("%s: Turning OFF %s", self.entity_id, self._heater_entity_id) - if self._heater_polarity_invert: - service = SERVICE_TURN_ON + if self._pwm: + if time.time() - self._last_heat_cycle_time >= self._min_on_cycle_duration.seconds or force: + data = {ATTR_ENTITY_ID: self.heater_or_cooler_entity} + _LOGGER.info("%s: Turning OFF %s", self.entity_id, self.heater_or_cooler_entity) + if self._heater_polarity_invert: + service = SERVICE_TURN_ON + else: + service = SERVICE_TURN_OFF + await self.hass.services.async_call(HA_DOMAIN, service, data) + self._last_heat_cycle_time = time.time() else: - service = SERVICE_TURN_OFF - await self.hass.services.async_call(HA_DOMAIN, service, data) - self._last_heat_cycle_time = time.time() + _LOGGER.info("%s: Reject request turning OFF %s: Cycle is too short", + self.entity_id, self.heater_or_cooler_entity) else: - _LOGGER.info("%s: Reject request turning OFF %s: Cycle is too short", - self.entity_id, self._heater_entity_id) + _LOGGER.info("%s: Change state of %s to %s", self.entity_id, + self.heater_or_cooler_entity, 0) + # self.hass.states.async_set(self._heater_entity_id, self._control_output) + data = {ATTR_ENTITY_ID: self.heater_or_cooler_entity, ATTR_VALUE: 0} + await self.hass.services.async_call( + self._get_number_entity_domain(self.heater_or_cooler_entity), + SERVICE_SET_VALUE, + data) async def async_set_preset_mode(self, preset_mode: str): """Set new preset mode. @@ -1034,7 +1124,7 @@ async def set_control_value(self): if abs(self._control_output) == self._difference: if not self._is_device_active: _LOGGER.info("%s: Output is %s. Request turning ON %s", self.entity_id, - self._difference, self._heater_entity_id) + self._difference, self.heater_or_cooler_entity) await self._async_heater_turn_on() self._time_changed = time.time() elif abs(self._control_output) > 0: @@ -1042,35 +1132,40 @@ async def set_control_value(self): else: if self._is_device_active: _LOGGER.info("%s: Output is 0. Request turning OFF %s", self.entity_id, - self._heater_entity_id) + self.heater_or_cooler_entity) await self._async_heater_turn_off() self._time_changed = time.time() else: - _LOGGER.info("%s: Change state of %s to %s", self.entity_id, self._heater_entity_id, + _LOGGER.info("%s: Change state of %s to %s", self.entity_id, + self.heater_or_cooler_entity, round(self._control_output, 2)) # self.hass.states.async_set(self._heater_entity_id, self._control_output) - data = {ATTR_ENTITY_ID: self._heater_entity_id, ATTR_VALUE: self._control_output} - await self.hass.services.async_call(NUMBER_DOMAIN, SERVICE_SET_VALUE, data) + data = {ATTR_ENTITY_ID: self.heater_or_cooler_entity, + ATTR_VALUE: abs(self._control_output)} + await self.hass.services.async_call( + self._get_number_entity_domain(self.heater_or_cooler_entity), + SERVICE_SET_VALUE, + data) async def pwm_switch(self, time_on, time_off, time_passed): """turn off and on the heater proportionally to control_value.""" if self._is_device_active: if time_on <= time_passed or self._force_off: _LOGGER.info("%s: ON time passed. Request turning OFF %s", self.entity_id, - self._heater_entity_id) + self.heater_or_cooler_entity) await self._async_heater_turn_off() self._time_changed = time.time() else: _LOGGER.info("%s: Time until %s turns OFF: %s sec", self.entity_id, - self._heater_entity_id, int(time_on - time_passed)) + self.heater_or_cooler_entity, int(time_on - time_passed)) else: if time_off <= time_passed or self._force_on: _LOGGER.info("%s: OFF time passed. Request turning ON %s", self.entity_id, - self._heater_entity_id) + self.heater_or_cooler_entity) await self._async_heater_turn_on() self._time_changed = time.time() else: _LOGGER.info("%s: Time until %s turns ON: %s sec", self.entity_id, - self._heater_entity_id, int(time_off - time_passed)) + self.heater_or_cooler_entity, int(time_off - time_passed)) self._force_on = False self._force_off = False diff --git a/custom_components/smart_thermostat/const.py b/custom_components/smart_thermostat/const.py index 1d5aeea..ba85700 100644 --- a/custom_components/smart_thermostat/const.py +++ b/custom_components/smart_thermostat/const.py @@ -19,6 +19,7 @@ DEFAULT_PRESET_SYNC_MODE = "none" CONF_HEATER = "heater" +CONF_COOLER = "cooler" CONF_INVERT_HEATER = 'invert_heater' CONF_SENSOR = "target_sensor" CONF_OUTDOOR_SENSOR = "outdoor_sensor" @@ -28,6 +29,7 @@ CONF_HOT_TOLERANCE = "hot_tolerance" CONF_COLD_TOLERANCE = "cold_tolerance" CONF_AC_MODE = "ac_mode" +CONF_FORCE_OFF_STATE = "force_off_state" CONF_MIN_CYCLE_DURATION = "min_cycle_duration" CONF_MIN_OFF_CYCLE_DURATION = "min_off_cycle_duration" CONF_MIN_CYCLE_DURATION_PID_OFF = 'min_cycle_duration_pid_off' diff --git a/custom_components/smart_thermostat/pid_controller/__init__.py b/custom_components/smart_thermostat/pid_controller/__init__.py index 3a2f785..9177828 100644 --- a/custom_components/smart_thermostat/pid_controller/__init__.py +++ b/custom_components/smart_thermostat/pid_controller/__init__.py @@ -11,8 +11,8 @@ class PID: error: float - def __init__(self, kp, ki, kd, ke=0, out_min=float('-inf'), out_max=float('+inf'), sampling_period=0, - cold_tolerance=0.3, hot_tolerance=0.3): + def __init__(self, kp, ki, kd, ke=0, out_min=float('-inf'), out_max=float('+inf'), + sampling_period=0, cold_tolerance=0.3, hot_tolerance=0.3): """A proportional-integral-derivative controller. :param kp: Proportional coefficient. :type kp: float @@ -80,6 +80,22 @@ def mode(self, mode): assert mode.upper() in ['AUTO', 'OFF'] self._mode = mode.upper() + @property + def out_max(self): + return self._out_max + + @out_max.setter + def out_max(self, out_max): + self._out_max = out_max + + @property + def out_min(self): + return self._out_min + + @out_min.setter + def out_min(self, out_min): + self._out_min = out_min + @property def sampling_period(self): return self._sampling_period