In short
Problem: Radiator thermostats measure the temperature right next to the hot radiator, a shared blueprint stopped reacting, and an update silently broke the heating.
Solution: Three small automations per room (Toggle, Target Sync, Calibration), a real room sensor fed into every valve, and a few safety nets that tell me when something drifts.
Tested with: Home Assistant, Zigbee2MQTT 2.14, 12 SONOFF TRVZB radiator valves, Zigbee temperature and motion sensors
Our house has nine heated rooms with twelve smart radiator valves. The goal was simple: warm when someone is in the room, a few degrees less when not, and no heating with the window open. Getting there took a few rebuilds. This is the setup that works now, and the problems we hit on the way.
The building blocks per room
Every room uses the same set of helpers. That makes the automations almost identical, and a new room is mostly copy and rename.
| Helper | Purpose |
|---|---|
input_boolean.heating_office_on | Master switch for the room (dashboard, Telegram, “all on/off” scripts) |
input_number.temperature_comfort_office | Comfort temperature, for example 21 °C |
input_number.temperature_eco_office | ECO temperature, for example 19 °C |
schedule.schedule_heating_office | When the room should be heated at all |
On top of that, each room has three automations:
- Toggle follows the master switch and turns the valve and the Target Sync on or off.
- Target Sync decides between Comfort and ECO and writes the target to the valve.
- Calibration feeds the real room temperature into the valve.
Why I dropped the blueprint
At first all rooms ran on one shared heating blueprint. It was convenient until it wasn’t: at the end of August it simply stopped reacting to some inputs, because an internal guard condition failed. One bug in a shared blueprint means every room is affected at once, and the logic is hard to debug from the outside.
I replaced it with three plain automations per room. That is more automations (33 for the whole house), but each one is short, does one thing, and shows up clearly in the trace when something goes wrong.
1. Toggle
When the master switch goes on, the valve is set to heat, the Target Sync is enabled and run once, so the right temperature is applied immediately. When it goes off, the Target Sync is disabled first, then the valve is switched off.
alias: Office - Heating - Toggle
triggers:
- trigger: state
entity_id: input_boolean.heating_office_on
to: "on"
id: turned_on
- trigger: state
entity_id: input_boolean.heating_office_on
to: "off"
id: turned_off
actions:
- choose:
- conditions:
- condition: trigger
id: turned_on
sequence:
- action: climate.set_hvac_mode
target:
entity_id: climate.office_heating_valve
data:
hvac_mode: heat
- action: automation.turn_on
target:
entity_id: automation.office_heating_target_sync
- action: automation.trigger
target:
entity_id: automation.office_heating_target_sync
- conditions:
- condition: trigger
id: turned_off
sequence:
- action: automation.turn_off
target:
entity_id: automation.office_heating_target_sync
- action: climate.set_hvac_mode
target:
entity_id: climate.office_heating_valve
data:
hvac_mode: "off"
mode: single
2. Target Sync
The Target Sync is where the rooms differ. In the office it follows a motion sensor: Comfort after two minutes of motion, ECO after ten minutes without. The delays stop the heating from jumping up and down when someone just walks through.
alias: Office - Heating - Target Sync
triggers:
- trigger: state
entity_id: binary_sensor.office_motion
to: "on"
for: { minutes: 2 }
- trigger: state
entity_id: binary_sensor.office_motion
to: "off"
for: { minutes: 10 }
- trigger: state
entity_id:
- input_number.temperature_comfort_office
- input_number.temperature_eco_office
- trigger: state
entity_id: input_boolean.heating_office_on
to: "on"
conditions:
- condition: state
entity_id: input_boolean.heating_office_on
state: "on"
actions:
- if:
- condition: state
entity_id: binary_sensor.office_motion
state: "on"
then:
- action: climate.set_temperature
target:
entity_id: climate.office_heating_valve
data:
temperature: "{{ states('input_number.temperature_comfort_office') | float }}"
else:
- action: climate.set_temperature
target:
entity_id: climate.office_heating_valve
data:
temperature: "{{ states('input_number.temperature_eco_office') | float }}"
mode: restart
Other rooms use other signals with the same pattern:
- Schedule only (kitchen, bedroom, bathroom): Comfort inside the schedule, ECO outside it.
- Schedule and presence (rooms that belong to one person): Comfort only if the schedule is on and that person is at home.
- A device as presence: in the gaming room the AV receiver is the best sign that someone is there. Receiver on means Comfort, receiver off means a lower setback.
3. Calibration: the part that matters most
A radiator thermostat measures the temperature where it sits: right next to the hot radiator. So it thinks the room is warm long before it is, and closes too early.
The SONOFF TRVZB can use an external temperature instead. In Zigbee2MQTT it has two relevant entities:
- a select “Temperature sensor”:
local_temperature(its own sensor) orremote_temperature(an external value) - a number “External temperature input” that receives the external value
The Calibration automation copies a real room sensor into that input whenever the reading has been stable for two minutes. It also checks the select, because after a reconnect or a battery change the valve sometimes falls back to its own sensor.
alias: Office - Climate - Calibration
triggers:
- trigger: state
entity_id: sensor.office_temperature
for: { minutes: 2 }
- trigger: state
entity_id: select.office_heating_valve_temperature_sensor_select
not_to:
- remote_temperature
- remote_source_offline
- unavailable
- unknown
conditions:
- condition: numeric_state
entity_id: sensor.office_temperature
above: 0
actions:
# switch the valve back to the external sensor if it reverted
- if:
- condition: not
conditions:
- condition: state
entity_id: select.office_heating_valve_temperature_sensor_select
state:
- remote_temperature
- remote_source_offline
then:
- action: select.select_option
target:
entity_id: select.office_heating_valve_temperature_sensor_select
data:
option: remote_temperature
continue_on_error: true
# feed the real room temperature into the valve
- action: number.set_value
target:
entity_id: number.office_heating_valve_external_temperature_input
data:
value: "{{ states('sensor.office_temperature') | float }}"
mode: single
Tip: leave remote_source_offline alone. It is the valve’s own fallback when the external value goes stale. If you force it back to remote_temperature, the valve heats with an old value.
The condition above: 0 keeps a sensor that reports 0 or nothing from cooling the house down. With the calibration in place, the room finally reaches the temperature it shows on the dashboard.
4. No heating with the door open
The living room has a terrace door and two windows, and the heating kept running when they were open. The pause automation switches the three valves off after a door or window has been open for one minute, and switches them back on when all of them are closed again.
The important detail: it does not touch the master switch. Instead it sets its own flag (input_boolean.heating_living_room_paused). Toggle and Target Sync leave the valves alone while the flag is on. Because the flag is a helper and not a wait in the automation, it also survives a Home Assistant restart. A start trigger restores the heating if everything was closed while Home Assistant was down.
triggers:
- trigger: state
entity_id: [binary_sensor.terrace_door, binary_sensor.living_room_window_1, binary_sensor.living_room_window_2]
to: "on"
for: { minutes: 1 }
id: opened
- trigger: state
entity_id: [binary_sensor.terrace_door, binary_sensor.living_room_window_1, binary_sensor.living_room_window_2]
from: "on"
to: "off"
id: closed
- trigger: homeassistant
event: start
id: ha_start
actions:
- choose:
- conditions: # opened while heating is on -> pause
- condition: trigger
id: opened
- condition: state
entity_id: input_boolean.heating_living_room_on
state: "on"
sequence:
- action: input_boolean.turn_on
target: { entity_id: input_boolean.heating_living_room_paused }
- action: climate.set_hvac_mode
target: { entity_id: [climate.living_room_heating_1, climate.living_room_heating_2, climate.living_room_heating_3] }
data: { hvac_mode: "off" }
- conditions: # all closed and paused by us -> resume
- condition: trigger
id: [closed, ha_start]
- condition: state
entity_id: [binary_sensor.terrace_door, binary_sensor.living_room_window_1, binary_sensor.living_room_window_2]
state: "off"
- condition: state
entity_id: input_boolean.heating_living_room_paused
state: "on"
sequence:
- action: input_boolean.turn_off
target: { entity_id: input_boolean.heating_living_room_paused }
- if:
- condition: state
entity_id: input_boolean.heating_living_room_on
state: "on"
then:
- action: climate.set_hvac_mode
target: { entity_id: [climate.living_room_heating_1, climate.living_room_heating_2, climate.living_room_heating_3] }
data: { hvac_mode: heat }
- action: automation.trigger
target: { entity_id: automation.living_room_heating_target_sync }
mode: queued
5. Safety nets
Automations fail quietly. These three make sure I hear about it:
- Child lock enforcement: after a battery change or a re-pair, a valve’s child lock can be off, and then anyone can turn the knob. If any of the twelve locks is off for 10 seconds, or Home Assistant starts, all locks are switched on again.
- Target mismatch alert: 10 minutes after a valve’s target or a Comfort/ECO value changes, a template checks every valve. If a target matches neither its room’s ECO nor its Comfort value (tolerance 0.25 °C), I get a Telegram message. Rooms with heating off, valves that are off and the paused living room are skipped. This catches a Target Sync that didn’t reach the valve.
- Zigbee2MQTT version alert: a message whenever Zigbee2MQTT runs a new version. The reason is the next section.
The core of the mismatch check, simplified to one valve:
{% set target = state_attr('climate.office_heating_valve', 'temperature') | float %}
{% set eco = states('input_number.temperature_eco_office') | float %}
{% set comfort = states('input_number.temperature_comfort_office') | float %}
{{ (target - eco) | abs > 0.25 and (target - comfort) | abs > 0.25 }}
The issues we had
| Problem | Cause | Fix |
|---|---|---|
| Rooms didn’t react to schedule or motion changes | The shared heating blueprint’s internal guard condition failed | Three own automations per room instead of the blueprint |
| Rooms stayed colder than the target | The valve measured next to the radiator | Calibration with a real room sensor |
| Valve went back to its own sensor | Reconnect or battery change resets the select | Calibration also triggers on the select and switches it back |
| Heating ran with the terrace door open | No pause logic | Door and window pause with its own flag |
| Calibration silently stopped working | An automatic update from Zigbee2MQTT 2.14.1 to 2.14.2 renamed the TRVZB sensor options | Automations switched to the new option names, plus a version change alert |
| Valves could be changed by hand | Child lock off after battery change or re-pair | Child lock enforcement |
| A valve sat at a wrong target unnoticed | A Target Sync that didn’t reach the valve | Target mismatch alert |
Watch out: if Zigbee2MQTT runs on the latest image and updates itself, a converter change can rename entities or options overnight. Nothing errors, the heating just quietly stops doing what you think it does. Either pin the version and update on purpose, or at least get notified when the version changes.
What I learned
- Small, boring automations per room beat one clever shared blueprint. They are easier to read, test and fix.
- A radiator thermostat without an external sensor heats to the wrong temperature. Feeding it a real room sensor is worth the extra automation.
- Use a flag helper instead of a wait for anything that must survive a restart.
- Every automation that can fail silently needs an alert that checks the result, not just the trigger.