From c0ee460dc0fa7a06f434d00b27fdb290feb0ae4f Mon Sep 17 00:00:00 2001 From: Dror Eiger <45061021+deiger@users.noreply.github.com> Date: Fri, 31 Jan 2025 13:03:08 +0200 Subject: [PATCH 1/3] Add support for cascading PIDs by controlling an inner climate. --- README.md | 31 +++++++++++++++++++ custom_components/smart_thermostat/climate.py | 25 ++++++++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d105b6f..a8564f9 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,28 @@ climate: pwm: 0 ``` +``` +climate: + - platform: smart_thermostat + name: Smart Thermostat Cascading PID Example + unique_id: smart_thermostat_outer_pid_example + heater: climate.smart_thermostat_single_on_off_heat_example + target_sensor: sensor.ambient_temperature2 + min_temp: 7 + max_temp: 28 + ac_mode: False + target_temp: 19 + keep_alive: + seconds: 60 + away_temp: 14 + kp: 5 + ki: 0.01 + kd: 500 + output_min: 7 + output_max: 28 + pwm: 0 +``` + ## Usage: The target sensor measures the ambient temperature while the heater switch controls an ON/OFF heating system.\ @@ -123,6 +145,15 @@ PID output value is the weighted sum of the control terms:\ `output = P + I + D`\ Output is then limited to 0% to 100% range to control the PWM. +#### Cascading PID +Optionally, 2 (or more) PIDs can be used in a cascading matter, e.g. for underfloor heating define an outer +PID between the room temperature and the floor temperature, and an inner PID between the floor +temperature and the PWM. See details [here]( +https://en.wikipedia.org/wiki/Proportional%E2%80%93integral%E2%80%93derivative_controller#Cascade_control). + +To enable, create the inner thermostate as detailed above, and then create another thermostate that will +control the inner one. + #### Outdoor temperature compensation Optionally, when an outdoor temperature sensor entity is provided and ke is set, the thermostat can automatically compensate building losses based on the difference between target temperature and diff --git a/custom_components/smart_thermostat/climate.py b/custom_components/smart_thermostat/climate.py index 0a674be..e7cc3d6 100644 --- a/custom_components/smart_thermostat/climate.py +++ b/custom_components/smart_thermostat/climate.py @@ -48,7 +48,9 @@ from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity, ClimateEntityFeature from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, HVACMode, HVACAction, PRESET_AWAY, @@ -59,6 +61,8 @@ PRESET_HOME, PRESET_SLEEP, PRESET_ACTIVITY, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, ) from . import DOMAIN, PLATFORMS @@ -750,7 +754,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: self.entity_id, self._control_output, hvac_mode) - await self._async_set_valve_value(self._control_output) + await self._async_set_valve_value(self._control_output, self._hvac_mode) # Clear the samples to avoid integrating the off period self._previous_temp = None self._previous_temp_time = None @@ -910,7 +914,7 @@ async def _async_control_heating( await self._async_heater_turn_off(force=True) else: self._control_output = self._output_min - await self._async_set_valve_value(self._control_output) + await self._async_set_valve_value(self._control_output, self._hvac_mode) self.async_write_ha_state() return @@ -1008,7 +1012,7 @@ async def _async_heater_turn_off(self, force=False): service = SERVICE_TURN_OFF await self.hass.services.async_call(HA_DOMAIN, service, data) - async def _async_set_valve_value(self, value: float): + async def _async_set_valve_value(self, value: float, hvac_mode: HVACMode): _LOGGER.info("%s: Change state of %s to %s", self.entity_id, ", ".join([entity for entity in self.heater_or_cooler_entity]), value) for heater_or_cooler_entity in self.heater_or_cooler_entity: @@ -1024,6 +1028,19 @@ async def _async_set_valve_value(self, value: float): VALVE_DOMAIN, SERVICE_SET_VALVE_POSITION, data) + elif heater_or_cooler_entity[0:8] == 'climate.': + if hvac_mode == HVACMode.OFF: + data = {ATTR_ENTITY_ID: heater_or_cooler_entity, ATTR_HVAC_MODE: hvac_mode} + await self.hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + data) + else: + data = {ATTR_ENTITY_ID: heater_or_cooler_entity, ATTR_TEMPERATURE: value, ATTR_HVAC_MODE: hvac_mode} + await self.hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + data) else: data = {ATTR_ENTITY_ID: heater_or_cooler_entity, ATTR_VALUE: value} await self.hass.services.async_call( @@ -1136,7 +1153,7 @@ async def set_control_value(self): self._time_changed = time.time() await self._async_heater_turn_off() else: - await self._async_set_valve_value(abs(self._control_output)) + await self._async_set_valve_value(abs(self._control_output), self._hvac_mode) async def pwm_switch(self): """turn off and on the heater proportionally to control_value.""" From be59643f07670c0d08b8b3bd5273d7b655f5ce9e Mon Sep 17 00:00:00 2001 From: Dror Eiger <45061021+deiger@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:46:25 +0200 Subject: [PATCH 2/3] Call `set_hvac_mode` on top of `set_temperature` Apparently even though `climate.set_temperature` accepts an `hvac_mode`, it does not turn the thermostat on. Also fix `_is_device_active` for climate. --- custom_components/smart_thermostat/climate.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/custom_components/smart_thermostat/climate.py b/custom_components/smart_thermostat/climate.py index e7cc3d6..2679f6b 100644 --- a/custom_components/smart_thermostat/climate.py +++ b/custom_components/smart_thermostat/climate.py @@ -942,6 +942,8 @@ def _is_device_active(self): try: # do not throw an error if the state is not yet available on startup for heater_or_cooler_entity in self.heater_or_cooler_entity: state = self.hass.states.get(heater_or_cooler_entity).state + if heater_or_cooler_entity[0:8] == 'climate.': + return state != HVACMode.OFF try: value = float(state) if value > 0: @@ -1029,13 +1031,12 @@ async def _async_set_valve_value(self, value: float, hvac_mode: HVACMode): SERVICE_SET_VALVE_POSITION, data) elif heater_or_cooler_entity[0:8] == 'climate.': - if hvac_mode == HVACMode.OFF: - data = {ATTR_ENTITY_ID: heater_or_cooler_entity, ATTR_HVAC_MODE: hvac_mode} - await self.hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - data) - else: + data = {ATTR_ENTITY_ID: heater_or_cooler_entity, ATTR_HVAC_MODE: hvac_mode} + await self.hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + data) + if hvac_mode != HVACMode.OFF: data = {ATTR_ENTITY_ID: heater_or_cooler_entity, ATTR_TEMPERATURE: value, ATTR_HVAC_MODE: hvac_mode} await self.hass.services.async_call( CLIMATE_DOMAIN, From cbeb97eb85077de4242a1bd892ae22f94ee0a329 Mon Sep 17 00:00:00 2001 From: Dror Eiger <45061021+deiger@users.noreply.github.com> Date: Tue, 11 Feb 2025 20:44:36 +0200 Subject: [PATCH 3/3] Only call `set_hvac_mode` if needed Calling it has adverse effects on the inner thermostat, resulting in rapid turn off and on of the controlled heater. --- custom_components/smart_thermostat/climate.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/custom_components/smart_thermostat/climate.py b/custom_components/smart_thermostat/climate.py index 2679f6b..f3a688a 100644 --- a/custom_components/smart_thermostat/climate.py +++ b/custom_components/smart_thermostat/climate.py @@ -1031,11 +1031,13 @@ async def _async_set_valve_value(self, value: float, hvac_mode: HVACMode): SERVICE_SET_VALVE_POSITION, data) elif heater_or_cooler_entity[0:8] == 'climate.': - data = {ATTR_ENTITY_ID: heater_or_cooler_entity, ATTR_HVAC_MODE: hvac_mode} - await self.hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - data) + state = self.hass.states.get(heater_or_cooler_entity).state + if hvac_mode != state: + data = {ATTR_ENTITY_ID: heater_or_cooler_entity, ATTR_HVAC_MODE: hvac_mode} + await self.hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + data) if hvac_mode != HVACMode.OFF: data = {ATTR_ENTITY_ID: heater_or_cooler_entity, ATTR_TEMPERATURE: value, ATTR_HVAC_MODE: hvac_mode} await self.hass.services.async_call(