Automating heating with smart thermostatic radiator valves, the full guide

One of the reason I started using Home Assistant years ago was being able to completely automate the heating in my apartment. Just for context I live in Germany and, as in most houses, the heating is distributed through radiators that can be controlled individually through valves.
The goal that I had was to replace every one of these “dumb” valves with smart TRVs which I would be able to control and calibrate automatically through external temperature sensors.

Although this should be quite easy to do, my first experience was quite painful because things that should be straightforward (like setting up a schedule in Home Assistant) became unnecessarily complicated. That’s why I decide to write this wiki.

What we’ll do

1. Install the Hardware (Zigbee TRVs) in Home Assistant
2. Set up calibration through external temperature sensors
3. Set up heating schedule
4. Create a dashboard to control everything
5. Understand the limitations of this setup

Note that there are some tools (like Better Thermostat) that allow you to automate parts of this wiki. However, after using them for few months, I found them unreliable (constantly introducing breaking changes) and unnecessarily complicated, so I decided to do everything myself.

1. Install the Hardware
(I’m not covering the part about setting up Zigbee in your Home Assistant installation, there are many guides covering that. I will assume you have a working Zigbee installation. I’m using ZHA).

I was looking for something

  • cheap
  • not linked to any cloud service, completely local
  • working both through zigbee and manually with controls

I ended up buying this model, which is one of the many Tuya clones

Turns out it has many quirks I discovered throughout the years, but nothing that can’t be solved through some smart scripting :slight_smile:

Adding the device in ZHA was quite straightforward (remember to go into the “wifi” mode on the device before adding it), although many of the entities that were added had no name so I had to do some reverse engineering to understand what is what. I believe with the updated version of ZHA nowadays this is not a problem anymore.
Anyway, once you manage to add the device in Home Assistant you’ll get something like this

I’ve never used/touched the entities of the configuration section. When I installed the device for the first time they were not even there. Remember to always leave the device in the “manual” section by scrolling to the “hand” symbol on the TRV led panel.

2. Set up calibration through external temperature sensors
We know the onboard temperature sensor will not be reliable whenever the heating is on, so we need an external temperature sensor. Here you have the freedom of choosing whatever temperature sensor you want. There are some cheap zigbee models that work pretty well (but have infrequent updates). In my case I’m using a mix of temperature sensors from Ecowitt (as I already have a weather station gateway) which update quite often, and some zigbee ones. Whatever can be integrated in Home Assistant will work.

The TRVs expose a Temperature Offset (in the Controls section) which can be used to calibrate the temperature to match an external sensor. Luckily there’s already someone who thought of that and made a handy blueprint which worked well for me (and should be device agnostic): TRV Calibrator - Calibrate your valve with an external sensor (probably TRV agnostic) .
Install the blueprint and set it up for every one of your radiator valve: you should then have an automation per device. Remember: you can have multiple TRVs in the same room linked to the same external temperature sensor!

The automation works pretty well. Here are some examples for 2 rooms


Of course in case the changes are too abrupt it could take a while for the automation to adjust, however this is not really an issue.

3. Set up heating schedule
This was probably the most difficult part.
My idea was to have two temperatures: an ECO temperature (16°C) and a HEAT temperature (19-20°C depending on the room). When the schedule is ON the TRV should go into HEAT and back to ECO when the schedule is OFF. Easy, right? :slight_smile:

The first step was to create a schedule. In order to do that you have to go to Settings → Devices & Services → Helpers → Create Helper → Schedule. Create one for every room you want to control the heating by dragging the mouse on the timetable.

Ok, now that we have a schedule we just need to create an automation to turn on the heating.

This is was not straightforward because I discovered that these TRVs have some hard limits that can’t be changed: they will only start heating when the difference in temperature between the target temperature and the measure temperature exceeds 3-5°C, which is not what we want. For this reason in my automation I always use a first target temperature of 30°C (to fully open the valve) and then set my target. As there’s no way to manually control the valve position, I think this is the best workaround.

This is one of my automation

and in YAML

Code
alias: Turn Heating Kitchen ON
description: ""
mode: single
triggers:
  - entity_id:
      - schedule.heating_kitchen
    from: "off"
    to: "on"
    trigger: state
conditions:
  - condition: template
    value_template: >-
      {{ state_attr('climate.tze200_hue3yfsn_ts0601_zonnsmartthermostat_2',
      'temperature')|float !=
      states('input_number.heat_temperature_kitchen')|float }}
    alias: Check that the target temperature is not already at heat
actions:
  - data:
      temperature: 30
      hvac_mode: heat
    target:
      entity_id: climate.tze200_hue3yfsn_ts0601_zonnsmartthermostat_2
    alias: First set temperature to 30 to open valve
    action: climate.set_temperature
  - delay:
      hours: 0
      minutes: 2
      seconds: 0
      milliseconds: 0
  - data:
      temperature: "{{ states('input_number.heat_temperature_kitchen') }}"
      hvac_mode: heat
    target:
      entity_id: climate.tze200_hue3yfsn_ts0601_zonnsmartthermostat_2
    alias: Then set the actual target temperature
    action: climate.set_temperature

There is a new entity: input_number.heat_temperature_kitchen. This is a simple input number entity that I use to dyamically control my HEAT temperature.
Of course we also need a automation to set back the ECO temperature whenever the schedule goes from ON to OFF.

Code
alias: "Turn OFF Heating in Kitchen "
description: ""
mode: single
triggers:
  - entity_id:
      - schedule.heating_kitchen
    from: "on"
    to: "off"
    trigger: state
conditions:
  - condition: state
    entity_id: automation.turn_heating_kitchen_on
    state: "on"
  - condition: template
    value_template: >-
      {{ state_attr('climate.tze200_hue3yfsn_ts0601_zonnsmartthermostat_2',
      'temperature')|float ==
      states('input_number.heat_temperature_kitchen')|float }}
    alias: Check that the target temperature is at heat
actions:
  - data:
      temperature: "{{ states('input_number.eco_temperature_kitchen') }}"
      hvac_mode: heat
    target:
      entity_id: climate.tze200_hue3yfsn_ts0601_zonnsmartthermostat_2
    action: climate.set_temperature

Also here there’s a new input number, input_number.eco_temperature_kitchen'.

You’ll need one of these automations for every room.

This simple system is already capable of maintaining the temperature in every room, as you see from this picture

The yellow temperature is the target temperature (notice the jump to 30°C at the beginning of the heat cycle), while the blue one is the actual temperature from the TRV (corrected with the offset).

4. Create a dashboard to control everything

At this point we already have something that should reliably working in an automatic way. But if we want to control it and tweak it on demand we need to have a dashboard that exposes all these controls, including every eco and heat temperatures.

This is what I was able to create; as usual the requirement was simplicity.

Every room has its own temperature control (for manual adjustments) with the observed temperature and temperature change (derivative sensor). The buttons Heat and Cold manually trigger the same automations we defined before, so that we don’t have to manually set a target temperature ourselves.

Notice that I have two TRVs in the Living Room connected to the same automation and sensor.

An additional card allows one to also turn on/off the heating schedules.

There’s an additional section to control the value of the heat/eco temperatures

Here is the complete YAML code (WARNING, that’s a lengthy one)

Code
theme: Backend-selected
title: Heating
path: heating
icon: mdi:heat-wave
subview: false
badges: []
cards:
  - type: custom:digital-clock
    firstLineFormat: H:mm
    secondLineFormat: cccc, dd LLLL y
  - type: vertical-stack
    title: Kitchen
    cards:
      - type: custom:mushroom-climate-card
        entity: climate.tze200_hue3yfsn_ts0601_zonnsmartthermostat_2
        fill_container: true
        hvac_modes:
          - heat
          - "off"
        collapsible_controls: false
        show_temperature_control: true
        secondary_info: none
        tap_action:
          action: more-info
        hold_action:
          action: more-info
        primary_info: state
      - type: horizontal-stack
        cards:
          - type: custom:mushroom-entity-card
            entity: sensor.temperature_2
            fill_container: false
            primary_info: state
            secondary_info: name
            name: Measured
          - type: custom:mushroom-entity-card
            entity: sensor.kitchen_temperature_change
            fill_container: false
            primary_info: state
            secondary_info: name
            name: Change
            icon: mdi:thermometer
            style: |
              :host {
                --icon-color: {% if states('sensor.kitchen_temperature_change') | float > 0 %} red {% else %} blue {% endif %};
                --text-color: {% if states('sensor.kitchen_temperature_change') | float > 0 %} red {% else %} blue {% endif %};
              }
      - type: horizontal-stack
        cards:
          - show_name: true
            show_icon: true
            styles:
              card:
                - "--mdc-ripple-color": blue
                - "--mdc-ripple-press-opacity": 0.5
            type: custom:button-card
            size: 20%
            color: gray
            tap_action:
              action: call-service
              service: automation.trigger
              service_data:
                entity_id: automation.turn_heating_kitchen_on
            name: Heat
            icon: mdi:radiator
            state:
              - operator: template
                value: |
                  [[[
                    return (states['climate.tze200_hue3yfsn_ts0601_zonnsmartthermostat_2'].attributes.temperature == states['input_number.heat_temperature_kitchen'].state)
                  ]]]
                color: rgb(255, 87, 53)
          - type: custom:button-card
            styles:
              card:
                - "--mdc-ripple-color": blue
                - "--mdc-ripple-press-opacity": 0.5
            size: 20%
            color: gray
            tap_action:
              action: call-service
              service: automation.trigger
              service_data:
                entity_id: automation.turn_off_heating_in_kitchen
            name: Cold
            icon: mdi:radiator-off
            state:
              - operator: template
                value: |
                  [[[
                    return (states['climate.tze200_hue3yfsn_ts0601_zonnsmartthermostat_2'].attributes.temperature == states['input_number.eco_temperature_kitchen'].state)
                  ]]]
                color: rgb(28, 128, 199)
  - type: vertical-stack
    title: Bedroom
    cards:
      - type: custom:mushroom-climate-card
        entity: climate.thermostat_bedroom_zonnsmartthermostat_3
        fill_container: true
        hvac_modes:
          - heat
          - "off"
        collapsible_controls: false
        show_temperature_control: true
        secondary_info: none
        tap_action:
          action: more-info
        hold_action:
          action: more-info
        primary_info: state
      - type: horizontal-stack
        cards:
          - type: custom:mushroom-entity-card
            entity: sensor.thermohygrometer_bedroom_zigbee_temperature
            fill_container: false
            primary_info: state
            secondary_info: name
            name: Entrance
          - type: custom:mushroom-entity-card
            entity: sensor.indoor_temperature
            fill_container: false
            primary_info: state
            secondary_info: name
            name: Window
          - type: custom:mushroom-entity-card
            entity: sensor.bedroom_temperature_change
            fill_container: false
            primary_info: state
            secondary_info: name
            name: Change
            icon: mdi:thermometer
            style: |
              :host {
                --icon-color: {% if states('sensor.bedroom_temperature_change') | float > 0 %} red {% else %} blue {% endif %};
                --text-color: {% if states('sensor.bedroom_temperature_change') | float > 0 %} red {% else %} blue {% endif %};
              }
      - type: horizontal-stack
        cards:
          - type: custom:button-card
            styles:
              card:
                - "--mdc-ripple-color": blue
                - "--mdc-ripple-press-opacity": 0.5
            size: 20%
            color: gray
            tap_action:
              action: call-service
              service: automation.trigger
              service_data:
                entity_id: automation.turn_on_heating_in_bedroom
            name: Heat
            icon: mdi:radiator
            state:
              - operator: template
                value: |
                  [[[
                    return (states['climate.thermostat_bedroom_zonnsmartthermostat_3'].attributes.temperature == states['input_number.heat_temperature_bedroom'].state)
                  ]]]
                color: rgb(255, 87, 53)
          - type: custom:button-card
            styles:
              card:
                - "--mdc-ripple-color": blue
                - "--mdc-ripple-press-opacity": 0.5
            size: 20%
            color: gray
            tap_action:
              action: call-service
              service: automation.trigger
              service_data:
                entity_id: automation.turn_off_heating_in_bedroom
            name: Cold
            icon: mdi:radiator-off
            state:
              - operator: template
                value: |
                  [[[
                    return (states['climate.thermostat_bedroom_zonnsmartthermostat_3'].attributes.temperature == states['input_number.eco_temperature_bedroom'].state)
                  ]]]
                color: rgb(28, 128, 199)
  - type: vertical-stack
    title: Living Room
    cards:
      - type: custom:mushroom-climate-card
        entity: climate.thermostat_heating_living_room_tv_zonnsmartthermostat_3
        fill_container: true
        hvac_modes:
          - heat
          - "off"
        collapsible_controls: false
        show_temperature_control: true
        secondary_info: name
        tap_action:
          action: more-info
        hold_action:
          action: more-info
        primary_info: state
        name: TV
      - type: custom:mushroom-climate-card
        entity: climate.thermostat_living_room_sofa_thermostat
        fill_container: true
        hvac_modes:
          - heat
          - "off"
        collapsible_controls: false
        show_temperature_control: true
        secondary_info: name
        tap_action:
          action: more-info
        hold_action:
          action: more-info
        primary_info: state
        name: Sofa
      - type: horizontal-stack
        cards:
          - type: custom:mushroom-entity-card
            entity: sensor.temperature_1
            fill_container: false
            primary_info: state
            secondary_info: name
            name: Plant
          - type: custom:mushroom-entity-card
            entity: sensor.wh45_temperature
            fill_container: false
            primary_info: state
            secondary_info: name
            name: Library
          - type: custom:mushroom-entity-card
            entity: sensor.living_room_temperature_change
            fill_container: false
            primary_info: state
            secondary_info: name
            name: Change
            icon: mdi:thermometer
            style: |
              :host {
                --icon-color: {% if states('sensor.living_room_temperature_change') | float > 0 %} red {% else %} blue {% endif %};
                --text-color: {% if states('sensor.living_room_temperature_change') | float > 0 %} red {% else %} blue {% endif %};
              }
      - type: horizontal-stack
        cards:
          - show_name: true
            show_icon: true
            styles:
              card:
                - "--mdc-ripple-color": blue
                - "--mdc-ripple-press-opacity": 0.5
            type: custom:button-card
            size: 20%
            color: gray
            tap_action:
              action: call-service
              service: automation.trigger
              data: {}
              target:
                entity_id: automation.turn_on_heating_in_living_room
            name: Heat
            icon: mdi:radiator
            state:
              - operator: template
                value: |
                  [[[
                    return (states['climate.thermostat_living_room_sofa_thermostat'].attributes.temperature == states['input_number.heat_temperature_living_room'].state || states['climate.thermostat_heating_living_room_tv_zonnsmartthermostat_3'].attributes.temperature == states['input_number.heat_temperature_living_room'].state)
                  ]]]
                color: rgb(255, 87, 53)
          - show_name: true
            show_icon: true
            type: custom:button-card
            styles:
              card:
                - "--mdc-ripple-color": blue
                - "--mdc-ripple-press-opacity": 0.5
            size: 20%
            color: gray
            tap_action:
              action: call-service
              service: automation.trigger
              data: {}
              target:
                entity_id: automation.turn_off_heating_in_living_room
            name: Cold
            icon: mdi:radiator-off
            state:
              - operator: template
                value: |
                  [[[
                    return (states['climate.thermostat_living_room_sofa_thermostat'].attributes.temperature == states['input_number.eco_temperature_living_room'].state || states['climate.thermostat_heating_living_room_tv_zonnsmartthermostat_3'].attributes.temperature == states['input_number.eco_temperature_living_room'].state)
                  ]]]
                color: rgb(28, 128, 199)
  - type: vertical-stack
    title: Office
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.tz2000_a476raq2_ts0201_temperature
        fill_container: false
        primary_info: state
        secondary_info: name
        name: Table
      - type: horizontal-stack
        cards: []
  - type: vertical-stack
    title: Schedules
    cards:
      - type: custom:mushroom-entity-card
        entity: automation.turn_on_heating_in_bedroom
        tap_action:
          action: toggle
        hold_action:
          action: more-info
        name: Schedule Heating Bedroom
      - type: custom:mushroom-entity-card
        entity: automation.turn_heating_kitchen_on
        tap_action:
          action: toggle
        hold_action:
          action: more-info
        name: Schedule Heating Kitchen
      - type: custom:mushroom-entity-card
        entity: automation.turn_on_heating_in_kitchen_home
        tap_action:
          action: toggle
        hold_action:
          action: more-info
        name: Schedule Heating Kitchen (@Home)
        icon: mdi:thermostat-box
      - type: custom:mushroom-entity-card
        entity: automation.turn_on_heating_in_living_room
        tap_action:
          action: toggle
        hold_action:
          action: more-info
        name: Schedule Heating Living Room
  - type: vertical-stack
    cards:
      - type: horizontal-stack
        title: Bedroom
        cards:
          - type: custom:numberbox-card
            border: true
            entity: input_number.eco_temperature_bedroom
            name: Eco Temp.
            icon: false
            unit: false
          - type: custom:numberbox-card
            border: true
            entity: input_number.heat_temperature_bedroom
            name: Heat Temp.
            icon: false
            unit: false
      - type: horizontal-stack
        title: Kitchen
        cards:
          - type: custom:numberbox-card
            border: true
            entity: input_number.eco_temperature_kitchen
            name: Eco Temp.
            icon: false
            unit: false
          - type: custom:numberbox-card
            border: true
            entity: input_number.heat_temperature_kitchen
            name: Heat Temp.
            icon: false
            unit: false
      - type: horizontal-stack
        title: Living Room
        cards:
          - type: custom:numberbox-card
            border: true
            entity: input_number.eco_temperature_living_room
            name: Eco Temp.
            icon: false
            unit: false
          - type: custom:numberbox-card
            border: true
            entity: input_number.heat_temperature_living_room
            name: Heat Temp.
            icon: false
            unit: false

5. Understand the limitations of this setup
Here we come to the quirks and problems of this setup.

  • The offset of every TRV is limited to the interval -5, 5. Sometimes it could happen that the offset is larger (especially when the radiator is really hot). Unfortunately there’s no way of fixing this.
  • The calibration automation sends a lot of messages to the TRVs to correct the offset: this could affect battery. I always turn off this automation when I’m not using the heating (e.g. in summer).
  • These TRVs don’t like rechargeable batteries: always use normal ones! I usually change the batteries at the beginning of winter every year. When changing the battery usually you need to perform calibration of TRV but it should connect automatically to Home Assistant.
  • There is no window detection mechanism implemented, although it is possible to do it.
  • The opening/closing of the valve is still a blackbox. For this reason the target temperature may not be precisely maintained. Packages like better thermostat can take care of this, however I found the accuracy to be good enough for me.
    …many more that I’ll add in the future.

Hi, this process is exactly what I want to do but I’m a bit stuck on adding the action, specifically the entity input_number.heat_temperature_kitchen.

I’m pretty new to HA so wondering where this is added, as it’s set in both the on/off actions, but I can’t find out where this is created. Would you mind covering that step off please?

Heyoh,
input numbers can be created in the same way as schedules, in the helpers section :+1:

Hi @guidocioni , Thanks for sharing this - I have got it all up and running and working really well. Would you mind sharing the yaml for your living room automations (On and off) ? I have the same as you (2 radiators in the same room but sometimes one TRV comes on and not the other and I cant work out why - I just wondered if my automations were set up the same as yours?
Thanks again for the hard work on setting this up and sharing it!

haven’t done anything specific

alias: Turn ON Heating in Living Room
description: ""
mode: single
triggers:
  - entity_id:
      - schedule.heating_living_room_schedule
    from: "off"
    to: "on"
    trigger: state
conditions:
  - condition: template
    value_template: >-
      {{
      state_attr('climate.thermostat_heating_living_room_tv_zonnsmartthermostat_3',
      'temperature')|float !=
      states('input_number.heat_temperature_living_room')|float }}
    alias: Check that the target temperature is not already at heat
  - condition: template
    value_template: >-
      {{ state_attr('climate.thermostat_living_room_sofa_thermostat',
      'temperature')|float !=
      states('input_number.heat_temperature_living_room')|float }}
    alias: Check that the target temperature is not already at heat
actions:
  - data:
      hvac_mode: heat
      temperature: 30
    target:
      entity_id:
        - climate.thermostat_heating_living_room_tv_zonnsmartthermostat_3
        - climate.thermostat_living_room_sofa_thermostat
    action: climate.set_temperature
  - delay:
      hours: 0
      minutes: 2
      seconds: 0
      milliseconds: 0
  - data:
      hvac_mode: heat
      temperature: "{{ states('input_number.heat_temperature_living_room') }}"
    target:
      entity_id:
        - climate.thermostat_heating_living_room_tv_zonnsmartthermostat_3
        - climate.thermostat_living_room_sofa_thermostat
    action: climate.set_temperature
1 Like

Thanks for this write-up! I’m trying to figure out how to set up my whole house system. We have a pump on our radiator system that kicks in any time the primary thermostat drops below the specified temp. Is Home Assistant able to replace that logic with “Turn on the pump anytime any of the valves are open” ?

I think your scope is quite different from what I was trying to do here.
There are other topics (and integrations) that are more suited for controlling a generic thermostat.
But in theory that’s definitely possible.