michaelharley.net

Smarter Thermostat with Home Assistant

7 min read

A smart thermostat was one of the first connected devices I purchased when I started building our smart home. I wanted automatic presence detection so the thermostat could set an away mode when we leave and "we're home" mode without us having to do anything. Never mind the fact that Rachelle and I both work from home and are both homebodies. On the rare occasion that we're both away from home, the house knows it, automatically! ;)

Throughout the year, I'd find myself fiddling with the set points though. 68°F (20°C) in February feels different than 68°F in August. So twice a year, as the seasons started to tip over into warmer or colder weather, I'd change the upper and lower bounds.

It feels weird to run the AC in winter or the heat in summer. We sleep with the thermostat set at 65°F (18°C). Our preference for day time temp is 68°F to 72°F (20°C to 22°C). So when we wake up, and toggle sleep mode off in Home Assistant, the thermostat sets itself to the day time values and turns on the heat to raise the temp from 65 to 68; even in summer. That bothers me. I'd rather the thermostat just set the lowest temp to 68 and allow the house temp to naturally rise.

Plus, I have all these damn sensors in the house, outside the house, and a weather forecast available. I feel like I have all the inputs needed to make some data-driven decisions.

A climate control dashboard showing a T6 Pro Thermostat set to cooling at 72°F on the left, with environment metrics in the center including indoor temperature of 73°F and humidity at 46%, and miscellaneous data on the right showing UV index and rain forecast, along with detailed air quality measurements for indoor and outdoor CO2, VOC, and particulate matter levels.
My climate dashboard.

Goal #

My goal was to stop running heat on summer mornings and AC on winter evenings. The set points stay fixed. What changes is the HVAC mode: heat-only, cool-only, or both.

Data #

For Home Assistant to pick the right mode, it needs to know what's going on outside, so I made a couple of template sensors.

Outdoor Blend #

It all comes down to one template sensor: sensor.outdoor_temp_blend. It's the average of three numbers: the current outdoor temperature, today's forecast high, and today's forecast low. The result tells me what kind of day this is rather than what the temperature is right this minute. That blend feeds sensor.thermostat_season, which labels the day heating, cooling or shoulder-season. The season is what picks the HVAC mode.

Why average instead of just reading the current outdoor temp? A 50°F dawn before a 75°F afternoon shouldn't kick the heater on for an hour at sunrise. The blend flattens out the time-of-day swings so the thermostat isn't reacting to a number that's about to change anyway.

- sensor:
    - name: "Outdoor Temp Blend"
      unique_id: outdoor_temp_blend
      device_class: temperature
      unit_of_measurement: "°F"
      state_class: measurement
      availability: >-
        {{ state_attr('weather.forecast_12_grimmauld_place', 'temperature') is not none
           and states('sensor.today_forecast_high') | float(none) is not none
           and states('sensor.today_forecast_low') | float(none) is not none }}
      state: >-
        {% set current = state_attr('weather.forecast_12_grimmauld_place', 'temperature') | float %}
        {% set high = states('sensor.today_forecast_high') | float %}
        {% set low = states('sensor.today_forecast_low') | float %}
        {{ ((current + high + low) / 3) | round(1) }}

Thermostat Season #

sensor.thermostat_season reads the outdoor blend and labels the day as one of three states:

  • cool once blend reaches 68°F, holds until blend drops below 65°F
  • heat once blend falls to 55°F, holds until blend rises above 58°F
  • heat_cool for everything in between

The mode scripts (thermostat_active_mode, thermostat_sleep_mode, thermostat_away_mode) read this sensor to decide whether the thermostat should run cooling only, heating only, or both with a deadband.

That 3°F gap on each side is on purpose. Without it, a blend hovering right at 68°F would flip the HVAC mode every time the value bounced from 67.9 to 68.1 across forecast updates. The hysteresis means the season has to meaningfully change before the mode does. Once it decides it's summer, it doesn't second-guess itself over one cool morning.

- sensor:
    - name: "Thermostat Season"
      unique_id: thermostat_season
      icon: >-
        {% set s = this.state %}
        {% if s == 'cool' %}mdi:snowflake
        {% elif s == 'heat' %}mdi:fire
        {% else %}mdi:swap-vertical{% endif %}
      availability: >-
        {{ states('sensor.outdoor_temp_blend') | float(none) is not none }}
      state: >-
        {% set blend = states('sensor.outdoor_temp_blend') | float %}
        {% set prev = this.state if this.state in ['heat','cool','heat_cool'] else 'heat_cool' %}
        {% if prev == 'cool' %}
          {# stay in cool until blend drops below 65 #}
          {{ 'heat_cool' if blend < 65 else 'cool' }}
        {% elif prev == 'heat' %}
          {# stay in heat until blend rises above 58 #}
          {{ 'heat_cool' if blend > 58 else 'heat' }}
        {% else %}
          {# in heat_cool, transition based on outer thresholds #}
          {% if blend >= 68 %}cool
          {% elif blend <= 55 %}heat
          {% else %}heat_cool
          {% endif %}
        {% endif %}

Helpers #

I built some helpers that allow me to easily set the temperature range. They're just input_number helpers, one pair per mode: day, night and away. The scripts below read them by name, so when I nudge the day time high from 72 to 71 on my dashboard, that's the number the thermostat gets the next time anything runs.

Thermostat Settings screen showing temperature modes, schedule, HVAC maintenance information, and battery status. Left panel displays Day Mode (68-72°F), Night Mode (60-65°F), and Away Mode (60-75°F) settings. Center shows daily schedule from 6:00 AM to 7:30 PM, HVAC and humidifier filter expiration dates of 2026-09-19 with 60-day replacement intervals. Right panel indicates outdoor blend temperature of 82.0°F, cool season logic, and thermostat battery level at full capacity.

Putting it all together now #

Three scripts do the actual work: thermostat_active_mode, thermostat_sleep_mode and thermostat_away_mode. They're deliberately dumb. Each one reads sensor.thermostat_season, picks a branch, and writes to the thermostat. That's the whole job. None of them know a thing about the weather.

Here's the day time one. I trimmed a couple of housekeeping steps off the end (it also flips some booleans and sets the fan mode) to keep the important part visible:

thermostat_active_mode:
  alias: Thermostat active mode
  sequence:
    - choose:
        - conditions:
            - condition: state
              entity_id: sensor.thermostat_season
              state: cool
          sequence:
            - action: climate.set_temperature
              target:
                entity_id: climate.t6_pro_z_wave_programmable_thermostat
              data:
                hvac_mode: cool
                temperature: "{{ states('input_number.thermostat_day_temp_high') | float }}"
        - conditions:
            - condition: state
              entity_id: sensor.thermostat_season
              state: heat
          sequence:
            - action: climate.set_temperature
              target:
                entity_id: climate.t6_pro_z_wave_programmable_thermostat
              data:
                hvac_mode: heat
                temperature: "{{ states('input_number.thermostat_day_temp_low') | float }}"
      default:
        - action: climate.set_temperature
          target:
            entity_id: climate.t6_pro_z_wave_programmable_thermostat
          data:
            hvac_mode: heat_cool
            target_temp_high: "{{ states('input_number.thermostat_day_temp_high') | float }}"
            target_temp_low: "{{ states('input_number.thermostat_day_temp_low') | float }}"
  mode: single

In cool season the script sets hvac_mode: cool and one number, the day time high. That's it. No lower bound anywhere in that call, so nothing can ask for heat. Winter is the same trick backwards, hvac_mode: heat and just the day time low. Only the shoulder season sends both numbers as a deadband, which is what I had before.

Sleep and away are the same shape. They just read their own pair of helpers.

What calls the scripts #

Mostly, nothing new. My wake, sleep and away automations already called these three scripts, because that's how the house has worked for a while now. They picked up the season awareness for free. I didn't have to touch a single one of them. So that's cool.

The one thing I did have to add is an automation for when the season itself changes:

- id: thermostat_reevaluate_on_season_change
  alias: Thermostat, re-evaluate on season change
  triggers:
    - trigger: state
      entity_id: sensor.thermostat_season
      to:
        - heat
        - cool
        - heat_cool
  conditions:
    - condition: template
      value_template: >-
        {{ trigger.from_state is not none
           and trigger.from_state.state in ['heat','cool','heat_cool'] }}
  actions:
    - choose:
        - conditions:
            - condition: state
              entity_id: input_boolean.thermostat_away_mode
              state: "on"
          sequence:
            - action: script.thermostat_away_mode
        - conditions:
            - condition: state
              entity_id: input_boolean.thermostat_night_mode
              state: "on"
          sequence:
            - action: script.thermostat_sleep_mode
        - conditions:
            - condition: state
              entity_id: input_boolean.thermostat_day_mode
              state: "on"
          sequence:
            - action: script.thermostat_active_mode
  mode: single

Without it, a season flip in the middle of a Tuesday afternoon would just sit there. Nothing would be calling a script until bedtime. This one re-runs whichever mode is already active so the thermostat catches up.

The condition looks fussier than it needs to be. It's there because the season sensor drops to unavailable for a split second now and then, usually when the weather integration hiccups or Home Assistant restarts. Without the guard, every one of those blips would re-fire the thermostat. I went back through the last couple of weeks of history and it happened 16 times. So, worth having.

Conclusion #

So, back to the thing that bugged me. Summer morning, house is sitting at 65, I toggle sleep mode off. The season is cool, so the script sends cool-only at 72 and there's no lower bound for the furnace to chase. The house drifts up to 68 on its own, for free, the way it would have anyway if I'd just left it alone.

I pulled the history to make sure I wasn't fooling myself. Over the last twelve days there were 13 mornings where the house was sitting at exactly 65°F when we got up, and the furnace ran for zero minutes. Under the old setup that's 13 mornings of running heat in the middle of summer.

I should say though, it's August. The heat branch has never actually fired for real. I've read it a dozen times and I think it's right. Ask me again in December.

I'm not an HVAC guy and there's almost certainly a smarter way to do this. What's your approach? Does your thermostat know what time of year it is, or do you fiddle with it twice a year like I used to?

Also on Mastodon (opens in a new tab) Lemmy (opens in a new tab) Bluesky (opens in a new tab)

Signal

Join the conversation