# Bosch BTH-RM Zigbee Thermostat: Home Assistant Blueprint to Control Your Heating

> Complete guide to integrating the Bosch BTH-RM Zigbee thermostat into Home Assistant via Zigbee2MQTT. Ready-to-use YAML blueprint, common pitfalls and the 2027 thermostat mandate.

- **Date**: 2026-03-04
- **Category**: Innovation
- **Author**: Romain Delfosse
- **URL**: https://www.romaindelfosse.fr/en/blog/thermostat-bosch-bth-rm-zigbee-home-assistant/

---

*Mandatory thermostat by 2030, smart radiators, Zigbee protocol… Looking to take back control of your heating without relying on the cloud? Here's a ready-to-use Home Assistant blueprint for the Bosch BTH-RM thermostat — plus the pitfalls nobody documents.*

---

Looking to integrate the [Bosch Room Thermostat II](https://www.zigbee2mqtt.io/devices/BTH-RM.html?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) (RBSH-RTH0-BAT-ZB-EU) into [Home Assistant](https://www.home-assistant.io/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) to control a smart radiator? After several weeks of testing and iterations, I'm sharing my complete YAML blueprint, ready to use, along with explanations of the pitfalls I encountered.

## Mandatory thermostat in France: what the law says

Before diving into the technical side, an important regulatory reminder. [Decree No. 2023-444 of 7 June 2023](https://www.service-public.gouv.fr/particuliers/actualites/A18805?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) mandates the installation of a **programmable thermostat** in all homes in France. The deadlines were [adjusted at the end of 2025](https://www.legrand.fr/maison-connectee/chauffage-thermostats-obligatoires-dans-les-logements-des-2027?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) by Decree No. 2025-1343:

- **New buildings**: mandatory from **1 January 2027** (for building permits filed from that date)
- **Existing homes**: deadline extended to **1 January 2030**
- **Since 2018**: already mandatory when replacing a boiler

The thermostat must support at least **four programming levels**: comfort, reduced, frost protection and off, with **room-by-room or zone-based regulation**. Good news: the law doesn't require a *smart* thermostat — a simple programmable one is enough. But if you're investing in hardware, you might as well make it do more than tick a regulatory box.

According to [ADEME](https://agirpourlatransition.ademe.fr/particuliers/amenager-maison/renover/installer-thermostat-programmable?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch), a programmable thermostat can save **up to 15% on heating bills**. A smart thermostat can go further thanks to intelligent adaptation to living habits.

On the funding side, grants are available: the ["Coup de pouce chauffage" subsidy](https://www.service-public.gouv.fr/particuliers/actualites/A18805?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) can cover part of the installation. Homeowners are responsible for the equipment, tenants for maintenance. An exemption is provided if the return on investment exceeds 10 years.

**The approach I present in this article goes well beyond the legal requirement**: a Bosch Zigbee thermostat paired with Home Assistant offers room-by-room regulation, advanced scheduling and zero cloud dependency — your data stays home, your heating works even if the Internet goes down.

## Why choose the Bosch BTH-RM Zigbee thermostat?

The Bosch Room Thermostat II is a battery-powered [Zigbee 3.0](https://www.zigbee2mqtt.io/devices/BTH-RM.html?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) thermostat. Unlike Wi-Fi solutions that saturate your home network and [depend on the manufacturer's cloud](https://forumdomotique.com/guides-tutoriels/debuter-domotique/quel-protocole-domotique-choisir-2025-comparatif-zigbee-zwave-wifi-thread-matter/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch), it communicates **locally** — no data leaves your home. Temperature and humidity sensor, readable LED display with rotary dial, approximately one year of battery life on AA batteries.

Compact (86 × 87 × 28 mm, 85 g), it blends discreetly into any room. Its main appeal for Home Assistant users: it can serve as a **physical remote** to control a connected electric radiator — via a `climate` entity in Home Assistant (generic thermostat, pilot wire module, Intuis radiator…).

## Prerequisites: hardware and software

Before getting started, make sure you have:

- **[Home Assistant](https://www.home-assistant.io/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch)** installed and running
- **[Zigbee2MQTT](https://www.zigbee2mqtt.io/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch)** configured with a Zigbee coordinator (Sonoff ZBDongle-P, Conbee II, etc.)
- **A smart radiator** controlled via a [`climate` entity](https://www.home-assistant.io/integrations/climate/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) in Home Assistant (generic thermostat, pilot wire module, Intuis radiator, etc.)
- **The Bosch BTH-RM thermostat** paired to your Zigbee network

## Pairing the Bosch BTH-RM with Zigbee2MQTT

Pairing is straightforward but worth a few notes. The [Zigbee2MQTT documentation for the BTH-RM](https://www.zigbee2mqtt.io/devices/BTH-RM.html?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) details the full procedure.

1. Enable pairing mode in Zigbee2MQTT ("Allow connection" button or `permit_join: true`)
2. On the Bosch thermostat, press and hold the **+** and **-** buttons simultaneously for 3 to 5 seconds
3. The Zigbee symbol flashes on the screen: the thermostat is searching for the network
4. Zigbee2MQTT detects the device as **Bosch BTH-RM**

Once paired, the thermostat exposes several entities in Home Assistant, including the `climate` entity we're interested in.

## The Bosch quirk: temperature range mode

This is the first pitfall I encountered. Unlike most Zigbee thermostats that expose a simple `temperature` attribute for the setpoint, the Bosch uses a **range mode** with two attributes:

- `target_temp_low`: low setpoint (e.g. 21°C)
- `target_temp_high`: high setpoint (e.g. 23°C)

This is confirmed by the `supported_features: 386` attribute, indicating `TARGET_TEMPERATURE_RANGE` support rather than simple `TARGET_TEMPERATURE`. The [climate entity documentation](https://developers.home-assistant.io/docs/core/entity/climate/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) in Home Assistant explains these different modes.

In practice, when you turn the dial on the thermostat, it's `target_temp_low` that changes and represents your heating setpoint. This is the value you need to listen to in your automations.

On the MQTT side, the setpoint is called `occupied_heating_setpoint` and expects a value in degrees (not centidegrees). Here's a typical [MQTT](https://www.home-assistant.io/integrations/mqtt/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) payload:

{% raw %}
```json
{
  "local_temperature": 22.3,
  "occupied_heating_setpoint": 21,
  "occupied_cooling_setpoint": 23,
  "system_mode": "heat",
  "operating_mode": "manual",
  "humidity": 47.6,
  "setpoint_change_source": "manual",
  "battery": 90
}
```
{% endraw %}

## Home Assistant Blueprint: bidirectional thermostat-radiator synchronisation

This [blueprint](https://www.home-assistant.io/docs/blueprint/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) provides **bidirectional synchronisation** between the Bosch thermostat and a generic Home Assistant thermostat controlling your radiator:

- When you change the temperature on the Bosch → the radiator follows
- When an automation modifies the target thermostat → the Bosch display updates
- When you turn one off or on → the other follows

### Installation

**Quick method**: click the button below to [import the blueprint directly](https://www.home-assistant.io/docs/automation/using_blueprints/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) into your Home Assistant:

[![Open your Home Assistant instance and show the blueprint import dialog with this blueprint pre-filled.](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fromaindelfosse%2Fha-blueprint-bosch-bth-rm%2Fblob%2Fmain%2Fbosch_bth_rm_thermostat.yaml)

**Manual method**:

1. Download the YAML file from the [GitHub repository](https://github.com/romaindelfosse/ha-blueprint-bosch-bth-rm?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch)
2. Copy it to your `config/blueprints/automation/custom/` folder
3. Reload automations in Home Assistant
4. Create a new automation based on this blueprint
5. Select your Bosch thermostat, enter its Zigbee2MQTT name, and choose your target thermostat

### The YAML code

{% raw %}
```yaml
blueprint:
  name: Bosch BTH-RM - Thermostat Control V5
  description: |
    Bidirectional thermostat control with Bosch Room Thermostat II (BTH-RM)

    Features:
    - Bidirectional temperature setpoint synchronisation
    - ON/OFF state synchronisation
    - Automatic manual mode on startup

    Note: The Bosch uses target_temp_low/high (range mode)

  domain: automation
  input:
    bosch_climate:
      name: Bosch BTH-RM Thermostat
      description: The climate entity of the Bosch Room Thermostat II
      selector:
        entity:
          filter:
            domain: climate

    bosch_z2m_name:
      name: Zigbee2MQTT Bosch Name
      description: "The exact device name in Zigbee2MQTT (e.g.: bedroom_climate_control)"
      selector:
        text:

    target_thermostat:
      name: Target Thermostat to Control
      description: The climate entity (generic thermostat) to control with the Bosch
      selector:
        entity:
          filter:
            domain: climate

    temp_min:
      name: Minimum Temperature
      description: Minimum allowed temperature
      default: 5
      selector:
        number:
          min: 4
          max: 15
          step: 0.5
          unit_of_measurement: "°C"

    temp_max:
      name: Maximum Temperature
      description: Maximum allowed temperature
      default: 30
      selector:
        number:
          min: 20
          max: 35
          step: 0.5
          unit_of_measurement: "°C"

mode: queued
max_exceeded: silent
max: 5

trigger:
  # Initialisation on Home Assistant start
  - platform: homeassistant
    event: start
    id: initialization

  # Any change on the Bosch
  - platform: state
    entity_id: !input bosch_climate
    id: bosch_change

  # Any change on the target thermostat
  - platform: state
    entity_id: !input target_thermostat
    id: target_change

condition:
  # Ignore unavailable/unknown states
  - condition: template
    value_template: >
      {{ trigger.id == 'initialization' or
         (trigger.to_state.state not in ['unavailable', 'unknown'] and
          trigger.from_state.state not in ['unavailable', 'unknown']) }}

action:
  - variables:
      bosch_entity: !input bosch_climate
      bosch_name: !input bosch_z2m_name
      target_entity: !input target_thermostat
      min_temp: !input temp_min
      max_temp: !input temp_max

  - choose:
      # ==========================================
      # INITIALISATION ON STARTUP
      # ==========================================
      - conditions:
          - condition: trigger
            id: initialization
        sequence:
          - delay:
              seconds: 10

          # Set Bosch to manual mode
          - service: mqtt.publish
            data:
              topic: "zigbee2mqtt/{{ bosch_name }}/set"
              payload: '{"operating_mode": "manual"}'

          - delay:
              milliseconds: 500

          # Sync from target thermostat to Bosch
          - variables:
              init_temp: "{{ state_attr(target_entity, 'temperature') | float(20) }}"
              init_state: "{{ states(target_entity) }}"
              init_mode: "{{ 'off' if init_state == 'off' else 'heat' }}"

          - service: mqtt.publish
            data:
              topic: "zigbee2mqtt/{{ bosch_name }}/set"
              payload: >
                {"occupied_heating_setpoint": {{ init_temp }}, "system_mode": "{{ init_mode }}"}

      # ==========================================
      # BOSCH CHANGE → TARGET
      # ==========================================
      - conditions:
          - condition: trigger
            id: bosch_change
        sequence:
          - variables:
              # The Bosch uses target_temp_low for heating
              bosch_temp: "{{ state_attr(bosch_entity, 'target_temp_low') | float(0) }}"
              bosch_state: "{{ states(bosch_entity) }}"
              target_temp: "{{ state_attr(target_entity, 'temperature') | float(0) }}"
              target_state: "{{ states(target_entity) }}"
              old_bosch_temp: "{{ trigger.from_state.attributes.target_temp_low | default(0) | float(0) }}"
              old_bosch_state: "{{ trigger.from_state.state }}"

          # Sync temperature if changed on Bosch
          - choose:
              - conditions:
                  - "{{ bosch_temp > 0 }}"
                  - "{{ bosch_temp != old_bosch_temp }}"
                  - "{{ (bosch_temp - target_temp) | abs > 0.1 }}"
                sequence:
                  - service: climate.set_temperature
                    target:
                      entity_id: "{{ target_entity }}"
                    data:
                      temperature: "{{ [[bosch_temp, min_temp] | max, max_temp] | min }}"

          # Sync ON/OFF state if changed on Bosch
          - choose:
              - conditions:
                  - "{{ bosch_state != old_bosch_state }}"
                  - "{{ bosch_state == 'off' and target_state != 'off' }}"
                sequence:
                  - service: climate.turn_off
                    target:
                      entity_id: "{{ target_entity }}"

              - conditions:
                  - "{{ bosch_state != old_bosch_state }}"
                  - "{{ bosch_state != 'off' and target_state == 'off' }}"
                sequence:
                  - service: climate.set_hvac_mode
                    target:
                      entity_id: "{{ target_entity }}"
                    data:
                      hvac_mode: heat

      # ==========================================
      # TARGET CHANGE → BOSCH
      # ==========================================
      - conditions:
          - condition: trigger
            id: target_change
        sequence:
          - variables:
              bosch_temp: "{{ state_attr(bosch_entity, 'target_temp_low') | float(0) }}"
              bosch_state: "{{ states(bosch_entity) }}"
              target_temp: "{{ state_attr(target_entity, 'temperature') | float(0) }}"
              target_state: "{{ states(target_entity) }}"
              old_target_temp: "{{ trigger.from_state.attributes.temperature | default(0) | float(0) }}"
              old_target_state: "{{ trigger.from_state.state }}"

          # Sync temperature if changed on target
          - choose:
              - conditions:
                  - "{{ target_temp > 0 }}"
                  - "{{ target_temp != old_target_temp }}"
                  - "{{ (target_temp - bosch_temp) | abs > 0.1 }}"
                sequence:
                  - service: mqtt.publish
                    data:
                      topic: "zigbee2mqtt/{{ bosch_name }}/set"
                      payload: '{"occupied_heating_setpoint": {{ target_temp }}}'

          # Sync ON/OFF state if changed on target
          - choose:
              - conditions:
                  - "{{ target_state != old_target_state }}"
                  - "{{ target_state == 'off' and bosch_state != 'off' }}"
                sequence:
                  - service: mqtt.publish
                    data:
                      topic: "zigbee2mqtt/{{ bosch_name }}/set"
                      payload: '{"system_mode": "off"}'

              - conditions:
                  - "{{ target_state != old_target_state }}"
                  - "{{ target_state != 'off' and bosch_state == 'off' }}"
                sequence:
                  - service: mqtt.publish
                    data:
                      topic: "zigbee2mqtt/{{ bosch_name }}/set"
                      payload: '{"system_mode": "heat"}'
```
{% endraw %}

## Technical design choices explained

A few points worth detailing for those who want to adapt this blueprint.

**Why `mode: queued` and `max: 5`?** The Bosch can send multiple rapid MQTT updates when you turn the dial. Queued mode ensures each change is processed in order, without losing any. The `max: 5` prevents excessive build-up if the Zigbee network slows down.

**Why `target_temp_low` and not `temperature`?** As explained above, the Bosch exposes `supported_features: 386`, indicating range mode. The classic `temperature` attribute doesn't exist on this entity. It's `target_temp_low` that carries the heating setpoint. The [climate entity documentation](https://developers.home-assistant.io/docs/core/entity/climate/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) details the `supported_features` calculation.

**Why publish via MQTT rather than use `climate.set_temperature`?** The Bosch in range mode expects `set_temperature` with both `target_temp_low` AND `target_temp_high`. Going through [MQTT](https://www.home-assistant.io/integrations/climate.mqtt/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) with `occupied_heating_setpoint` is more direct and reliable for modifying only the heating setpoint.

**Why compare `trigger.from_state` instead of using a simple threshold?** This is the main anti-loop mechanism. Instead of adding a 500ms delay and hoping the state has stabilised, we directly compare the old state (`trigger.from_state.attributes.target_temp_low`) with the new one. If the value hasn't actually changed on the trigger source entity, we do nothing. This eliminates synchronisation loops deterministically rather than temporally.

**Why a clamp `[[val, min] | max, max] | min` instead of `>=` / `<=` conditions?** With conditions, an out-of-range value is simply ignored — the user turns the dial and nothing happens, with no feedback. With a clamp, the value is brought within the allowed range: if the user requests 35°C and the max is 30°C, the radiator receives 30°C. The behaviour is predictable and transparent.

**Why `climate.set_hvac_mode: heat` instead of `climate.turn_on`?** `turn_on` restores the last HVAC mode used, which could be `cool` or `auto` depending on the target entity. `set_hvac_mode: heat` is explicit and ensures the radiator switches to heating mode, not cooling.

**Why a single combined MQTT payload on initialisation?** On startup, the old version sent the setpoint and ON/OFF state in two separate MQTT commands with a 2-second delay between them. The current version combines both in a single payload `{"occupied_heating_setpoint": X, "system_mode": "heat"}`. It's faster and avoids a transient state where the thermostat might have the right setpoint but the wrong state (or vice versa).

## Pitfalls to avoid with the Bosch BTH-RM thermostat

I lost an entire weekend on these issues. Here's how to avoid them.

**The `supported_features: 386`** — If you copy a blueprint designed for a standard thermostat (Tuya, Aqara…), it won't work with the Bosch. Most [community blueprints](https://community.home-assistant.io/c/blueprints-exchange/53?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) listen to the `temperature` attribute, which doesn't exist on the BTH-RM. Always check your climate entity's attributes in [Developer Tools → States](https://www.home-assistant.io/docs/tools/dev-tools/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch).

**The `operating_mode`** — The Bosch sometimes starts in `schedule` mode (internal programming). In this mode, it ignores setpoints sent by Home Assistant and follows its own schedule. The blueprint forces a switch to `manual` mode on HA startup to avoid this issue.

**The `setpoint_change_source` detection** — The Bosch's MQTT payload includes a `setpoint_change_source` field that reads `manual` when the user turns the dial, and `externally` when the setpoint comes from outside (via MQTT). This is useful for debugging if you observe synchronisation loops. The blueprint uses a more robust approach: comparing `trigger.from_state` vs current state, which detects whether the value has actually changed on the trigger source entity.

**The `Delivery failed` on ZCL commands** — If you see this error in the [Zigbee2MQTT](https://www.zigbee2mqtt.io/guide/usage/debug.html?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) logs, it's often because the Bosch is a battery-powered device that sleeps between transmissions. Make sure your [Zigbee mesh](https://www.zigbee2mqtt.io/guide/usage/improve_network_range_and_stability.html?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) is solid (add routers nearby). The [dedicated thread on the Home Assistant forum](https://community.home-assistant.io/t/solved-bosch-bth-ii-thermostat-remote-temperature-sensor/611615?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) documents these cases well.

## My complete heating home automation setup

For context, here's my complete setup using this blueprint:

| Room | Thermostat (control) | Radiator (heating) |
|------|---------------------|-------------------|
| Bedroom | Bosch BTH-RM (Zigbee) | Intuis (Zigbee) |
| Living room | Aqara W100 (Zigbee) | NodOn pilot wire (Zigbee) |
| Other rooms | Aqara W100 (Zigbee) | Intuis (Zigbee) |

The blueprint presented here manages the bedroom. For the Aqara W100, I use a different blueprint adapted to their specific requirements (proprietary MQTT topics, PMTSD handling). If you're interested, let me know on [LinkedIn](https://www.linkedin.com/in/romaindelfosse/?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch-bth-rm) — I'll prioritize it if there's demand.

## Conclusion: going further

This blueprint gives you locally controlled, bidirectionally synced heating with no cloud. To go further, here's what I'm exploring:

- **Time-based scheduling with Schedy** (AppDaemon): set different temperature ranges for weekdays/weekends, with overrides for remote work or guests
- **Open window detection**: automatically turn off heating when the Bosch detects a rapid temperature drop (the `window_detection` is [exposed in Zigbee2MQTT attributes](https://www.zigbee2mqtt.io/devices/BTH-RM.html?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch))
- **Dedicated dashboard**: a Lovelace card showing the current temperature, setpoint, humidity and battery level of the Bosch

**Note**: the Bosch BTH-RM also supports cooling mode (`cool`) via `occupied_cooling_setpoint` and `target_temp_high`. This blueprint is designed for heating (`heat` / `off`) — the most common use case. If you have a reversible air conditioner and want to extend the blueprint, the [GitHub repository](https://github.com/romaindelfosse/ha-blueprint-bosch-bth-rm?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) is open to contributions.

---

With the [obligation to install a programmable thermostat](https://www.service-public.gouv.fr/particuliers/actualites/A18805?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) by 2030, now is a good time to invest in a long-lasting home automation solution. The blueprint source code is available on [GitHub](https://github.com/romaindelfosse/ha-blueprint-bosch-bth-rm?utm_source=romaindelfosse&utm_medium=blog&utm_campaign=thermostat-bosch) — issues and pull requests are welcome. Got a question about the blueprint or the Bosch integration? <a href="#" data-open-modal="contact-modal">Let's talk</a>.
