|
| 1 | +--- |
| 2 | +title: Automating the HVAC System |
| 3 | +# date: 2025-07-16 |
| 4 | +# categories are Family, Photography, Places, Projects, Reviews, Software, Thoughts |
| 5 | +category: Projects |
| 6 | +tags: |
| 7 | +- homeassistant |
| 8 | +- automation |
| 9 | +- smarthome |
| 10 | +- climate |
| 11 | +media_subpath: /assets/img/posts/automating-hvac-system/ |
| 12 | +image: |
| 13 | + path: preview.jpg |
| 14 | + lqip: preview.lqip.jpg |
| 15 | +--- |
| 16 | + |
| 17 | +In the [last post]({% post_url 2025-07-16-hvac-helper-sensors %}), we created a bunch of sensors |
| 18 | +and automations to make it possible to control the HVAC system. This time, we're going to build the |
| 19 | +portions of the system which control the thermostat, raising and lowering the temperature as |
| 20 | +desired. |
| 21 | + |
| 22 | +This is the weirdest one and has a separate script to accompany the automation. To describe the goal, |
| 23 | +we're trying to determine where the the warmest (when AC is running) room is and set the cooling |
| 24 | +to run until _that_ room is at the target temperature. This has the unfortunate side effect of |
| 25 | +cooling the rest of the rooms beyond the target, but I'm fine with that (since my office is the |
| 26 | +hottest room in the house so I get to benefit from it). However, because wee want to make sure that |
| 27 | +we're getting to the actual desired temperature and because of the way thermostats work, we have to |
| 28 | +tell it to cool _beyond_ the point where it thinks it needs to be. |
| 29 | + |
| 30 | +The result is this... well _long script_. A lot of the length is simply documentation for the inputs |
| 31 | +to the script, but there's more complex stuff in there too |
| 32 | + |
| 33 | +```yaml |
| 34 | +{% raw %} |
| 35 | +script: |
| 36 | + update_thermostat_temp: |
| 37 | + alias: Climate - Update Thermostat Temperature |
| 38 | + mode: restart |
| 39 | + description: >- |
| 40 | + This script handles checking the current temperatures, the desired temperature, |
| 41 | + and the HVAC mode (all passed into this script) and then instructs the |
| 42 | + HVAC system to turn on/off accordingly by setting the target temperature |
| 43 | + of the thermostat in charge. |
| 44 | +
|
| 45 | + This script expects to work with a single thermostat; if you have multiple |
| 46 | + thermostats, set up an automation for each. |
| 47 | +
|
| 48 | + This script only anticipates working with an enabled HVAC system, meaning |
| 49 | + in "cool" or "heat" mode. Currently, it does not work with "heat_cool". |
| 50 | + If the HVAC system is in "off" mode, then do not call this script. It also |
| 51 | + does not pay any attention to away mode; that should be handled separately, |
| 52 | + perhaps as a condition in the automation. |
| 53 | +
|
| 54 | + fields: |
| 55 | + buffer: |
| 56 | + name: Buffer |
| 57 | + description: >- |
| 58 | + A buffer applied to the temperature when reading which helps prevent |
| 59 | + rapid cycling |
| 60 | + example: "0.5" |
| 61 | + default: 0.5 |
| 62 | + selector: |
| 63 | + number: |
| 64 | + min: 0 |
| 65 | + max: 2 |
| 66 | + step: 0.1 |
| 67 | + mode: box |
| 68 | + |
| 69 | + overshoot: |
| 70 | + name: Overshoot |
| 71 | + description: >- |
| 72 | + This will be added/subtracted to the target temperature in order to |
| 73 | + ensure the thermostat stays engaged until next iteration of this script |
| 74 | + example: "2" |
| 75 | + default: 2 |
| 76 | + selector: |
| 77 | + number: |
| 78 | + min: 0 |
| 79 | + max: 3 |
| 80 | + step: 0.1 |
| 81 | + mode: box |
| 82 | + |
| 83 | + thermostat_temp_sensor: |
| 84 | + name: Thermostat Temperature Sensor |
| 85 | + description: >- |
| 86 | + The temperature the thermostat currently thinks it is. This can often |
| 87 | + be different than the temperature in multiple rooms, yet controls the |
| 88 | + real-world behavior of the system, so we need to include it |
| 89 | + example: "sensor.my_thermostat_temperature" |
| 90 | + required: true |
| 91 | + selector: |
| 92 | + entity: |
| 93 | + filter: |
| 94 | + - device_class: temperature |
| 95 | + domain: sensor |
| 96 | + |
| 97 | + thermostat: |
| 98 | + name: Thermostat |
| 99 | + description: The thermostat which should be controlled by this script |
| 100 | + example: "climate.my_thermostat" |
| 101 | + required: true |
| 102 | + selector: |
| 103 | + entity: |
| 104 | + filter: |
| 105 | + - domain: climate |
| 106 | + |
| 107 | + room_temp_sensors: |
| 108 | + name: Room Temperature Sensors |
| 109 | + description: >- |
| 110 | + A collection of room temperatures which should be considered when |
| 111 | + calculating whether to engage or disengage the HVAC system. These are |
| 112 | + likely to be temp sensors in the rooms you want covered. |
| 113 | + example: "['sensor.office_temperature', 'sensor.bedroom_temperature']" |
| 114 | + required: true |
| 115 | + selector: |
| 116 | + entity: |
| 117 | + filter: |
| 118 | + - device_class: temperature |
| 119 | + domain: sensor |
| 120 | + multiple: true |
| 121 | + |
| 122 | + room_target_temp_entities: |
| 123 | + name: Room Target Temperature Entities |
| 124 | + description: >- |
| 125 | + A collection of entities which contain the target temperatures for |
| 126 | + each of the rooms. This can be a single desired temp (e.g. "cool to 78") |
| 127 | + or it can be a list (e.g. "cool this room to 75, that room to 78") |
| 128 | + example: "['input_number.office_target_temperature', 'input_number.bedroom_target_temperature']" |
| 129 | + required: true |
| 130 | + selector: |
| 131 | + entity: |
| 132 | + filter: |
| 133 | + - domain: input_number |
| 134 | + multiple: true |
| 135 | + |
| 136 | + current_hvac_mode: |
| 137 | + name: Current HVAC Mode |
| 138 | + description: >- |
| 139 | + The mode which the HVAC system is currently in. Accepted values are |
| 140 | + "cool" and "heat". |
| 141 | + example: cool |
| 142 | + required: true |
| 143 | + selector: |
| 144 | + select: |
| 145 | + options: |
| 146 | + - cool |
| 147 | + - heat |
| 148 | + |
| 149 | + variables: |
| 150 | + buffer: "{{ buffer | default(0.5) | float }}" |
| 151 | + overshoot: "{{ overshoot | default(2) | float }}" |
| 152 | + |
| 153 | + # add the thermostat temp sensor to the list of room temp sensors |
| 154 | + all_temp_sensors: > |
| 155 | + {{ room_temp_sensors + [thermostat_temp_sensor] }} |
| 156 | +
|
| 157 | + # Gather the temperatures for all the sensors |
| 158 | + all_current_temps: > |
| 159 | + {{ all_temp_sensors | map('states') | select('match', '^[0-9.]+$') | map('int') | list }} |
| 160 | +
|
| 161 | + # Gather all the target temperatures |
| 162 | + all_target_temps: > |
| 163 | + {{ room_target_temp_entities | map('states') | select('match', '^[0-9.]+$') | map('int') | list }} |
| 164 | +
|
| 165 | + # We'll also need the thermostat current temp for future use |
| 166 | + current_thermostat_temp: "{{ states(thermostat_temp_sensor) | int }}" |
| 167 | + |
| 168 | + # Determine the temp to use as the current temp; max temp if cooling, min temp |
| 169 | + # for heating. More description: if we're heating, we want to make the |
| 170 | + # coldest room the current temp so that can be heated to the desired temp. |
| 171 | + # For cooling, we want to cool the hottest room to the target temp |
| 172 | + current_aggregate_temp: >- |
| 173 | + {% if current_hvac_mode == "cool" %} |
| 174 | + {{ all_current_temps | max }} |
| 175 | + {% else %} |
| 176 | + {{ all_current_temps | min }} |
| 177 | + {% endif %} |
| 178 | +
|
| 179 | + # Determine the temp to use as the target temp; min temp if cooling, max temp |
| 180 | + # for heating. |
| 181 | + current_target_temp: >- |
| 182 | + {% if current_hvac_mode == "cool" %} |
| 183 | + {{ all_target_temps | min }} |
| 184 | + {% else %} |
| 185 | + {{ all_target_temps | max }} |
| 186 | + {% endif %} |
| 187 | +
|
| 188 | + sequence: |
| 189 | + - alias: Decide whether we should engage the HVAC and set the target temp |
| 190 | + if: |
| 191 | + - alias: Test to see if we're in cooling mode |
| 192 | + condition: template |
| 193 | + value_template: >- |
| 194 | + {{ current_hvac_mode == "cool" }} |
| 195 | + then: |
| 196 | + - alias: Set the variables for cooling |
| 197 | + variables: |
| 198 | + should_engage: >- |
| 199 | + {{ current_aggregate_temp > current_target_temp + buffer }} |
| 200 | + target_engaged_temp: >- |
| 201 | + {{ current_target_temp - overshoot }} |
| 202 | + target_disengaged_temp: >- |
| 203 | + {{ current_thermostat_temp + overshoot }} |
| 204 | + else: |
| 205 | + - alias: Set the variables for heating |
| 206 | + variables: |
| 207 | + should_engage: >- |
| 208 | + {{ current_aggregate_temp < current_target_temp - buffer }} |
| 209 | + target_engaged_temp: >- |
| 210 | + {{ current_target_temp + overshoot }} |
| 211 | + target_disengaged_temp: >- |
| 212 | + {{ current_thermostat_temp - overshoot }} |
| 213 | +
|
| 214 | + - alias: Choose the target temp as appropriate based on mode and temp |
| 215 | + if: |
| 216 | + - alias: Test to see if we need to engage the thermostat |
| 217 | + condition: template |
| 218 | + value_template: >- |
| 219 | + {{ should_engage }} |
| 220 | + then: |
| 221 | + - alias: Use the engage values for the target |
| 222 | + variables: |
| 223 | + actual_target_temp: "{{ target_engaged_temp }}" |
| 224 | + else: |
| 225 | + - alias: Use the disengaged value for the target |
| 226 | + variables: |
| 227 | + actual_target_temp: "{{ target_disengaged_temp }}" |
| 228 | + |
| 229 | + - alias: Check to see if the HVAC system is disabled; don't continue if it's not |
| 230 | + condition: state |
| 231 | + entity_id: binary_sensor.disable_upstairs_climate_operation |
| 232 | + state: "off" |
| 233 | + |
| 234 | + - alias: Check to see if the thermostat is on; don't continue if it's not |
| 235 | + condition: template |
| 236 | + value_template: >- |
| 237 | + {{ is_state(thermostat, "heat") or is_state(thermostat, "cool") }} |
| 238 | +
|
| 239 | + - alias: Update the thermostat's temperature |
| 240 | + action: climate.set_temperature |
| 241 | + target: |
| 242 | + entity_id: >- |
| 243 | + {{ thermostat }} |
| 244 | + data: |
| 245 | + temperature: "{{ actual_target_temp }}" |
| 246 | +{% endraw %} |
| 247 | +``` |
0 commit comments