all systems self-hosted

~/selfhostjournal $

← All articles

Heating automations in Home Assistant: per-room logic, TRV calibration and the issues we hit

7–10 minutes

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.

HelperPurpose
input_boolean.heating_office_onMaster switch for the room (dashboard, Telegram, “all on/off” scripts)
input_number.temperature_comfort_officeComfort temperature, for example 21 °C
input_number.temperature_eco_officeECO temperature, for example 19 °C
schedule.schedule_heating_officeWhen 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) or remote_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

ProblemCauseFix
Rooms didn’t react to schedule or motion changesThe shared heating blueprint’s internal guard condition failedThree own automations per room instead of the blueprint
Rooms stayed colder than the targetThe valve measured next to the radiatorCalibration with a real room sensor
Valve went back to its own sensorReconnect or battery change resets the selectCalibration also triggers on the select and switches it back
Heating ran with the terrace door openNo pause logicDoor and window pause with its own flag
Calibration silently stopped workingAn automatic update from Zigbee2MQTT 2.14.1 to 2.14.2 renamed the TRVZB sensor optionsAutomations switched to the new option names, plus a version change alert
Valves could be changed by handChild lock off after battery change or re-pairChild lock enforcement
A valve sat at a wrong target unnoticedA Target Sync that didn’t reach the valveTarget 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.

Everything here worked in my setup at the time of writing. Software changes, and your setup is different: back up first, test carefully, and use the steps at your own risk. Details in the legal notice.