The Problem
Most preheat implementations fire a fixed number of minutes before the scheduled heating time, say 30 minutes early regardless of conditions. This works sometimes but is wasteful on mild days when the house barely needs heating, and insufficient on cold days when it needs longer. One of my biggest frustrations moving from Nest to a Hive thermostat was losing the smart preheat feature. Nest learns how long your home takes to heat up and starts early automatically so you wake up to a warm house. Hive has no equivalent (with no subscription at least). I wanted to recreate that behaviour in Home Assistant without relying on a learning algorithm, using a simple calibrated warm-up rate instead.
The Approach
This is a pure YAML implementation with no additional dependencies beyond standard HA helpers. If you want a self-learning approach that automatically refines its own timing from real heating cycles, there is a more advanced Pyscript-based option here. But if you want a transparent, easy to understand solution you can adapt to your own setup, read on.
This automation calculates the required lead time dynamically based on:
- The difference between current temperature and the next scheduled target
- A calibrated warm-up rate (degrees per minute your home heats up)
- A small fixed margin to ensure you hit target on time
On a mild day when the house is already close to target it fires late or not at all. On a cold morning when there's a large temperature gap it fires early. It adapts automatically.
How It Works
The automation runs every 5 minutes during two windows, 05:00–08:00 ahead of the morning schedule, and 16:00–22:00 ahead of the evening schedule. It looks up the next scheduled heating time, calculates how many minutes are needed to reach the target at the current warm-up rate, adds a margin, and fires if the time to target is within that threshold.
A preheat_done boolean prevents it firing repeatedly once it has triggered. This is reset after each window closes.
What You Need
Create these helpers first:
input_datetime.morning_start_time— your morning heating start timeinput_datetime.evening_start_time— your evening heating start timeinput_number.morning_target_temperature— morning target in °Cinput_number.evening_target_temperature— evening target in °Cinput_number.heating_warm_up_rate— degrees per minute your home heats up (start with 0.1 and calibrate)input_boolean.preheat_done— preheat lock flaginput_boolean.hive_boost_mode— boost mode flag (or adapt to your own)input_boolean.holiday_mode— holiday mode flag (or adapt to your own)
Calibrating the Warm-Up Rate
Note the current temperature and target temperature, then time how long it takes to reach target after your boiler fires. Divide the temperature rise by the minutes taken. For example, if the house rises 2°C in 20 minutes, your rate is 0.1°C/min. Adjust input_number.heating_warm_up_rate accordingly. You may need to set different values for winter and summer shoulder seasons.
The Automation
yaml
alias: Heating - Smart Preheat
description: >
Preheat before scheduled times using dynamic inputs. Calculates minutes
needed based on temp difference divided by heating_warm_up_rate, plus a
5-minute margin.
triggers:
- minutes: /5
trigger: time_pattern
conditions:
- condition: state
entity_id: input_boolean.holiday_mode
state: "off"
- condition: state
entity_id: person.your_person
state: home
- condition: state
entity_id: input_boolean.hive_boost_mode
state: "off"
- condition: state
entity_id: input_boolean.preheat_done
state: "off"
- condition: or
conditions:
- condition: time
after: 05:00:00
before: 08:00:00
- condition: time
after: "16:00:00"
before: "22:00:00"
actions:
- choose:
- conditions:
- condition: template
value_template: "{{ (preheat_calc | from_json).should_preheat }}"
sequence:
- variables:
target_temp: "{{ (preheat_calc | from_json).next_target }}"
current_set_temp: >
{{ state_attr('climate.your_thermostat','temperature') | float(0) }}
hvac_mode: "{{ states('climate.your_thermostat') }}"
- choose:
- conditions:
- condition: template
value_template: "{{ hvac_mode != 'heat' }}"
sequence:
- action: climate.set_hvac_mode
target:
entity_id: climate.your_thermostat
data:
hvac_mode: heat
- choose:
- conditions:
- condition: template
value_template: "{{ current_set_temp != target_temp }}"
sequence:
- action: climate.set_temperature
target:
entity_id: climate.your_thermostat
data:
temperature: "{{ target_temp }}"
- target:
entity_id: input_datetime.preheat_start_time
data:
datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}"
action: input_datetime.set_datetime
- data:
title: Smart Preheat Status
message: >
Smart Preheat fired! Current Temp: {{
state_attr('climate.your_thermostat','current_temperature') }}°C
Target Temp: {{ (preheat_calc | from_json).next_target }}°C
action: persistent_notification.create
- target:
entity_id: input_boolean.preheat_done
action: input_boolean.turn_on
variables:
preheat_calc: >
{% set now_dt = now() %}
{% set current_temp = state_attr('climate.your_thermostat','current_temperature') | float(0) %}
{% set warmup_rate = states('input_number.heating_warm_up_rate') | float(0.1) %}
{% set preheat_margin = 5 %}
{% set schedules = [
{'name':'Morning', 'entity':'input_datetime.morning_start_time',
'target': states('input_number.morning_target_temperature') | float(21.0)},
{'name':'Evening', 'entity':'input_datetime.evening_start_time',
'target': states('input_number.evening_target_temperature') | float(22.0)}
] %}
{% set today_midnight = now_dt.replace(hour=0, minute=0, second=0, microsecond=0) %}
{% set ns = namespace(next_name='None', next_dt=now_dt + timedelta(days=365),
next_target=states('input_number.morning_target_temperature') | float(21.0)) %}
{% for s in schedules %}
{% set sched_str = states(s.entity) %}
{% if sched_str not in ['unknown','unavailable',''] %}
{% set parts = sched_str.split(':') %}
{% set h = parts[0] | int %}
{% set m = parts[1] | int %}
{% set sched_dt = today_midnight + timedelta(hours=h, minutes=m) %}
{% if sched_dt <= now_dt %}
{% set sched_dt = sched_dt + timedelta(days=1) %}
{% endif %}
{% if sched_dt < ns.next_dt %}
{% set ns.next_name = s.name %}
{% set ns.next_dt = sched_dt %}
{% set ns.next_target = s.target %}
{% endif %}
{% endif %}
{% endfor %}
{% if ns.next_name == 'None' %}
{{ {'next_name': None, 'next_dt': None, 'next_target': None,
'minutes_needed': 0, 'minutes_to_target': 0, 'preheat_margin': preheat_margin,
'trigger_threshold': 0, 'should_preheat': False} | to_json }}
{% else %}
{% set temp_diff = ns.next_target - current_temp %}
{% if temp_diff > 0 %}
{% set minutes_needed = (temp_diff / warmup_rate) | round(0, 'ceil') %}
{% else %}
{% set minutes_needed = 0 %}
{% endif %}
{% set minutes_to_target = ((ns.next_dt - now_dt).total_seconds() / 60) %}
{% set trigger_threshold = minutes_needed + preheat_margin %}
{% set should_preheat = (trigger_threshold >= minutes_to_target) %}
{{ {'next_name': ns.next_name, 'next_dt': ns.next_dt.isoformat(),
'next_target': ns.next_target, 'minutes_needed': minutes_needed,
'minutes_to_target': minutes_to_target, 'preheat_margin': preheat_margin,
'trigger_threshold': trigger_threshold, 'should_preheat': should_preheat} | to_json }}
{% endif %}
mode: single
Reset Preheat Lock
You also need this automation to reset the lock after each window:
yaml
alias: Reset Preheat Lock
description: Resets preheat_done flag after morning and evening preheat windows close
triggers:
- at: 09:00:00
trigger: time
- at: "22:15:00"
trigger: time
actions:
- target:
entity_id: input_boolean.preheat_done
action: input_boolean.turn_off
mode: single
Notes
- Replace
climate.your_thermostatandperson.your_personwith your own entities - The preheat windows (05:00–08:00 and 16:00–22:00) can be adjusted to suit your schedule
- The 5-minute margin (
preheat_margin) can be increased if you want a bigger safety buffer - If you have TRV radiator valves, trigger a separate automation to open them to room temperatures when
input_boolean.preheat_doneflips to on