ESP32 Multi-Zone Heating Controller with ESPHome and Home Assistant

Build an ESP32 multi-zone heating controller with ESPHome and Home Assistant. Control room thermostats, zone valves and shared boiler demand safely.

A single smart thermostat is easy. A multi-zone heating system is where the control logic becomes much more interesting.

With one ESP32, several temperature sensors and a relay interface, you can create independent room thermostats while still controlling one shared boiler or heat source. Home Assistant then provides the schedules, setpoints, dashboards and automations.

Living Room sensor ─→ Zone 1 thermostat ─→ Zone 1 valve
Bedroom sensor    ─→ Zone 2 thermostat ─→ Zone 2 valve
Office sensor     ─→ Zone 3 thermostat ─→ Zone 3 valve
                                      │
                                      └─→ ANY zone calling?
                                              │
                                              └─→ Boiler enable

The critical design rule is simple: each thermostat controls only its own zone, while the boiler runs whenever at least one zone is requesting heat.

That avoids a common multi-zone bug where one room reaches temperature, turns its output off and accidentally shuts down the boiler even though another room is still cold.

What This Project Does

  • Creates an independent ESPHome climate entity for each heating zone.
  • Reads separate room temperatures.
  • Controls one valve, actuator or heating relay per zone.
  • Generates a shared boiler demand when any zone is heating.
  • Adds a configurable valve-opening delay before starting the boiler.
  • Immediately removes boiler demand when the last zone stops heating.
  • Exposes every thermostat to Home Assistant for schedules and automations.
  • Keeps the basic thermostat logic running locally on the ESP32 even if Home Assistant is temporarily unavailable.

Best Use Cases

The architecture works particularly well with hydronic heating systems where a boiler feeds several radiator or underfloor-heating zones.

  • Radiator zones with motorised valves.
  • Underfloor heating manifolds with electrothermal actuators.
  • Several room loops sharing one boiler.
  • Electric heating zones where each output controls a suitably rated contactor.
  • Workshop, greenhouse or outbuilding heating with several independently controlled areas.

It is not a universal replacement for the original boiler safety controls. Flame supervision, pump protection, high-limit thermostats, pressure controls and manufacturer interlocks should remain part of the original heating appliance.

Central Controller vs One ESP32 per Room

ArchitectureAdvantagesDisadvantages
One central ESP32Local aggregation, simple boiler logic, one power supply, no network dependency between zonesRequires sensor/valve cabling back to the controller
One ESP32 per roomVery flexible placement, short local sensor wiringShared boiler demand normally depends on Home Assistant/MQTT or extra inter-node logic
Commercial thermostats + ESP32 boiler interfaceKeeps existing controlsLess direct control and more integration work

For a new DIY installation, the central ESP32 approach is usually the cleanest because the boiler-demand decision remains local. Home Assistant can disappear for an hour and the rooms can still regulate temperature.

Example Three-Zone Hardware

  • ESP32 DevKit or another ESP32 board with enough safe GPIOs.
  • Three room-temperature sensors. The example uses DS18B20 sensors on one 1-Wire bus.
  • Three zone outputs for valves or actuator interfaces.
  • One boiler-enable output if the boiler is not already controlled by valve end switches.
  • 4.7 kΩ resistor for the DS18B20 1-Wire data pull-up.
  • Relay / opto-isolated interface board suitable for the control voltage used by the heating equipment.
  • A stable 5 V supply for the ESP32 and relay interface.

For room-air sensing, mount DS18B20 probes where they measure room temperature rather than radiator or pipe temperature. An SHT40/SHT45 is usually a better room sensor electrically and mechanically, but multiple identical SHT4x devices share the same default I²C address. A multi-zone central controller therefore needs separate I²C buses, an I²C multiplexer, or a different sensor topology if you use several SHT4x sensors.

Safety: Do Not Treat a Relay Board as a Heating Appliance

An ESP32 GPIO is only a logic signal. It should not directly switch boiler mains wiring, pumps or high-current heaters.

  • Prefer dry-contact thermostat inputs provided by the boiler or heating controller.
  • For mains-powered zone actuators, use correctly rated interposing relays or contactors.
  • Do not assume the inexpensive PCB relay’s printed current rating is adequate for a motor, pump or large resistive heater.
  • Keep SELV/low-voltage ESP32 wiring physically separated from mains wiring.
  • Use suitable fusing, enclosure, strain relief and earthing.
  • If the heating equipment uses a proprietary digital thermostat bus, do not short its terminals as if they were a simple dry-contact input.

If your zone valves have built-in end switches, the most robust conventional arrangement is often to let those end switches enable the boiler. The ESP32 then only commands the valves. The boiler cannot fire until a valve has physically opened.

Example GPIO Allocation

FunctionESP32 GPIONotes
1-Wire temperature busGPIO324.7 kΩ pull-up to 3.3 V
Living room zone relayGPIO25General-purpose output
Bedroom zone relayGPIO26General-purpose output
Office zone relayGPIO27General-purpose output
Boiler enable relayGPIO33General-purpose output

These are sensible pins on a common ESP32-WROOM DevKit. Check your exact board before copying them. Avoid flash pins and be careful with boot-strapping pins, especially when relay modules can force a logic level during reset.

Three DS18B20 Sensors on One 1-Wire Bus

ESP32 GPIO32 ─────┬──── Living Room DS18B20 DQ
                    ├──── Bedroom DS18B20 DQ
                    └──── Office DS18B20 DQ

3.3 V ── 4.7 kΩ ───┘

All sensor VDD → 3.3 V
All sensor GND → GND

Current ESPHome uses the one_wire bus component together with dallas_temp sensors. When you first boot the ESP32 with only the bus configured, ESPHome logs the detected 64-bit sensor addresses. Record each address and assign it permanently to the correct room.

ESPHome Base Configuration

esphome:
  name: heating-controller
  friendly_name: Multi Zone Heating

esp32:
  board: esp32dev
  framework:
    type: esp-idf

logger:

api:

ota:
  - platform: esphome

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  ap:
    ssid: "Heating Controller Fallback"
    password: !secret fallback_password

captive_portal:

Configure the 1-Wire Temperature Sensors

one_wire:
  - platform: gpio
    pin: GPIO32
    id: heating_1wire

sensor:
  - platform: dallas_temp
    one_wire_id: heating_1wire
    address: 0x1111111111111128
    id: living_temp
    name: "Living Room Temperature"
    update_interval: 10s

  - platform: dallas_temp
    one_wire_id: heating_1wire
    address: 0x2222222222222228
    id: bedroom_temp
    name: "Bedroom Temperature"
    update_interval: 10s

  - platform: dallas_temp
    one_wire_id: heating_1wire
    address: 0x3333333333333328
    id: office_temp
    name: "Office Temperature"
    update_interval: 10s

Replace the example addresses with the addresses reported in your ESPHome logs. Do not rely on sensor index numbers in a permanent installation because the index ordering can change if a sensor is replaced or another device is added to the bus.

Zone Outputs and Boiler Relay

This example assumes an active-low relay board. If your relay module is active high, remove inverted: true.

switch:
  - platform: gpio
    id: zone_living
    name: "Living Room Zone Valve"
    pin:
      number: GPIO25
      inverted: true
    restore_mode: ALWAYS_OFF
    on_turn_on:
      - script.execute: update_boiler_demand
    on_turn_off:
      - script.execute: update_boiler_demand

  - platform: gpio
    id: zone_bedroom
    name: "Bedroom Zone Valve"
    pin:
      number: GPIO26
      inverted: true
    restore_mode: ALWAYS_OFF
    on_turn_on:
      - script.execute: update_boiler_demand
    on_turn_off:
      - script.execute: update_boiler_demand

  - platform: gpio
    id: zone_office
    name: "Office Zone Valve"
    pin:
      number: GPIO27
      inverted: true
    restore_mode: ALWAYS_OFF
    on_turn_on:
      - script.execute: update_boiler_demand
    on_turn_off:
      - script.execute: update_boiler_demand

  - platform: gpio
    id: boiler_enable
    name: "Boiler Enable"
    pin:
      number: GPIO33
      inverted: true
    restore_mode: ALWAYS_OFF

For commissioning, it is useful to expose the zone relays by name as above. Once the system is proven, you can mark them internal: true so normal users operate the climate entities rather than manually forcing valves.

The Important Part: Aggregate Boiler Demand

The boiler should run if Living OR Bedroom OR Office is requesting heat.

Boiler demand =
    Living zone ON
 OR Bedroom zone ON
 OR Office zone ON

The script below waits 20 seconds before energising the boiler. That gives a motorised valve time to start opening. If all zones stop calling during the delay, the script re-checks the zone states and leaves the boiler off.

script:
  - id: update_boiler_demand
    mode: restart
    then:
      - if:
          condition:
            lambda: |-
              return id(zone_living).state ||
                     id(zone_bedroom).state ||
                     id(zone_office).state;
          then:
            - if:
                condition:
                  switch.is_off: boiler_enable
                then:
                  - delay: 20s
                  - if:
                      condition:
                        lambda: |-
                          return id(zone_living).state ||
                                 id(zone_bedroom).state ||
                                 id(zone_office).state;
                      then:
                        - switch.turn_on: boiler_enable
          else:
            - switch.turn_off: boiler_enable

This software delay is useful for simple systems, but a real zone-valve end switch is better when available because it confirms the valve has actually opened. A timer only assumes that it opened.

Create the Living Room Thermostat

climate:
  - platform: thermostat
    name: "Living Room Heating"
    id: living_climate
    sensor: living_temp

    heat_action:
      - switch.turn_on: zone_living

    idle_action:
      - switch.turn_off: zone_living

    min_heating_off_time: 60s
    min_heating_run_time: 120s
    min_idle_time: 30s

    heat_deadband: 0.3 °C
    heat_overrun: 0.2 °C

    default_preset: Home
    on_boot_restore_from: memory

    preset:
      - name: Home
        default_target_temperature_low: 21 °C
        mode: HEAT

      - name: Eco
        default_target_temperature_low: 18 °C
        mode: HEAT

      - name: Away
        default_target_temperature_low: 15 °C
        mode: HEAT

    visual:
      min_temperature: 10 °C
      max_temperature: 25 °C
      temperature_step: 0.5 °C

ESPHome’s thermostat controller applies hysteresis around the setpoint. With a 21 °C setpoint, a 0.3 °C heat deadband and 0.2 °C heat overrun, the zone begins heating after temperature falls sufficiently below the target and stops only after moving above it by the configured overrun. That prevents the valve from chattering around exactly 21.0 °C.

Add the Bedroom and Office Zones

The other climate entities are almost identical. Only the sensor, output and default setpoint change.

  - platform: thermostat
    name: "Bedroom Heating"
    id: bedroom_climate
    sensor: bedroom_temp

    heat_action:
      - switch.turn_on: zone_bedroom
    idle_action:
      - switch.turn_off: zone_bedroom

    min_heating_off_time: 60s
    min_heating_run_time: 120s
    min_idle_time: 30s
    heat_deadband: 0.3 °C
    heat_overrun: 0.2 °C

    default_preset: Home
    on_boot_restore_from: memory
    preset:
      - name: Home
        default_target_temperature_low: 19 °C
        mode: HEAT
      - name: Eco
        default_target_temperature_low: 17 °C
        mode: HEAT
      - name: Away
        default_target_temperature_low: 15 °C
        mode: HEAT

    visual:
      min_temperature: 10 °C
      max_temperature: 25 °C
      temperature_step: 0.5 °C

  - platform: thermostat
    name: "Office Heating"
    id: office_climate
    sensor: office_temp

    heat_action:
      - switch.turn_on: zone_office
    idle_action:
      - switch.turn_off: zone_office

    min_heating_off_time: 60s
    min_heating_run_time: 120s
    min_idle_time: 30s
    heat_deadband: 0.3 °C
    heat_overrun: 0.2 °C

    default_preset: Home
    on_boot_restore_from: memory
    preset:
      - name: Home
        default_target_temperature_low: 20 °C
        mode: HEAT
      - name: Eco
        default_target_temperature_low: 17 °C
        mode: HEAT
      - name: Away
        default_target_temperature_low: 15 °C
        mode: HEAT

    visual:
      min_temperature: 10 °C
      max_temperature: 25 °C
      temperature_step: 0.5 °C

How the Logic Behaves

LivingBedroomOfficeBoiler
IdleIdleIdleOFF
HeatingIdleIdleON after valve delay
HeatingHeatingIdleON
IdleHeatingHeatingON
IdleIdleHeatingON
IdleIdleIdleOFF immediately

Notice that the boiler state is not tied to any single thermostat. It is derived from the combined demand.

Why You Should Not Put Boiler On/Off Directly Inside Every Thermostat

A tempting configuration is:

Zone 1 heat_action → zone 1 valve ON + boiler ON
Zone 1 idle_action → zone 1 valve OFF + boiler OFF

Zone 2 heat_action → zone 2 valve ON + boiler ON
Zone 2 idle_action → zone 2 valve OFF + boiler OFF

That fails as soon as two zones heat at the same time. If Zone 1 becomes satisfied first, its idle action turns the shared boiler off even though Zone 2 still needs heat.

Shared resources must be controlled from an aggregate demand signal, not independently by every consumer.

Valve Opening Delay: Timer vs End Switch

MethodHow it worksReliability
Fixed delayWait 10–60 seconds after valve commandSimple but assumes the valve moved
Valve end switchBoiler enabled only after valve physically reaches open positionPreferred
Always-open hydraulic bypassProvides minimum flow if valves closeHydraulic protection, not proof of valve position

Many common motorised hydronic valves include a microswitch that closes only after the valve opens. In a conventional heating system those switches are often wired in parallel to create boiler demand. Keeping that arrangement can be safer and simpler than recreating it entirely in software.

Underfloor Heating Actuators Need Longer Timing

Electrothermal manifold actuators are different from fast motorised valves. Many take several minutes to travel from closed to open.

  • Do not assume a 20-second boiler delay is appropriate.
  • Check the actuator opening time in its datasheet.
  • Some systems deliberately keep the circulation pump running while actuators move.
  • If several loops share a manifold, hydraulic balancing still matters even when each room has a smart thermostat.
  • Frequent short thermostat cycles are undesirable; use sensible deadband and minimum run times.

What Happens if Home Assistant Goes Offline?

Because the thermostat controller, temperature sensors, zone outputs and boiler-demand script all run inside ESPHome on the ESP32, the basic heating regulation continues locally.

You lose Home Assistant dashboards, schedules and remote setpoint changes while HA is offline, but the last thermostat configuration remains on the controller. This is one of the main reasons to keep the core heat/no-heat decision out of a large chain of Home Assistant automations.

Home Assistant Integration

Each ESPHome thermostat appears in Home Assistant as a normal climate entity. Home Assistant can display the current temperature, target temperature, HVAC mode and current action such as heating or idle.

You can add three thermostat cards to a dashboard, or use a compact climate card for each room.

type: vertical-stack
cards:
  - type: thermostat
    entity: climate.living_room_heating
    name: Living Room

  - type: thermostat
    entity: climate.bedroom_heating
    name: Bedroom

  - type: thermostat
    entity: climate.office_heating
    name: Office

Scheduling Setpoints in Home Assistant

Home Assistant can change setpoints using the climate actions without owning the thermostat loop itself.

alias: Weekday Morning Heating
triggers:
  - trigger: time
    at: "06:30:00"

actions:
  - action: climate.set_temperature
    target:
      entity_id: climate.living_room_heating
    data:
      temperature: 21

  - action: climate.set_temperature
    target:
      entity_id: climate.bedroom_heating
    data:
      temperature: 19

  - action: climate.set_temperature
    target:
      entity_id: climate.office_heating
    data:
      temperature: 18

This is a good division of responsibility: ESPHome regulates temperature locally; Home Assistant decides what temperature you want and when.

Presence-Based Heating

Once every room has its own climate entity, presence automations become much more useful. Instead of turning the whole boiler on or off, Home Assistant can lower only unused zones.

  • Reduce office setpoint after working hours.
  • Lower bedroom temperature during the day.
  • Use an Away preset when nobody is home.
  • Preheat occupied rooms before people arrive.
  • Keep a frost-protection setpoint even when the normal schedule is off.

Avoid aggressive occupancy logic that changes setpoints every few minutes. Buildings have thermal inertia; heating control should generally respond more slowly than lighting automation.

Adding a Fourth, Fifth or Sixth Zone

The software pattern scales easily. Add another temperature sensor, another zone output and another thermostat, then include the new relay state in the boiler-demand OR expression.

return id(zone_living).state ||
       id(zone_bedroom).state ||
       id(zone_office).state ||
       id(zone_kitchen).state ||
       id(zone_guest).state;

The practical limit is usually GPIO count and wiring rather than thermostat logic. For larger systems, an MCP23017 GPIO expander, dedicated relay board or multiple distributed ESP32 nodes may be cleaner.

Using Separate ESP32 Room Sensors

Sometimes all zone valves are in one plant room but room sensors cannot be wired back to the controller. In that case, separate ESPHome nodes can publish room temperatures and the central controller can import them.

However, if the central ESP32 obtains temperatures through Home Assistant, heating control now depends on Home Assistant and the network. For comfort monitoring this may be acceptable; for frost protection or a critical heating installation, wired local sensors or a more fault-tolerant control path are preferable.

Sensor Failure Strategy

A failed temperature sensor should not silently leave a zone permanently heating.

  • Watch ESPHome logs for invalid or unavailable sensor states.
  • Use Home Assistant alerts for room-temperature sensors that become unavailable.
  • Design output restore states so a reboot starts from a safe condition.
  • Do not bypass the boiler’s independent high-temperature or pressure safety devices.
  • For unattended properties, consider a separate low-temperature alarm independent of the main thermostat logic.

Relay Boot Behaviour

Heating controls should never pulse randomly during ESP32 boot. Active-low relay boards deserve particular attention because a floating or boot-sensitive GPIO can briefly energise an output.

  • Use safe GPIOs.
  • Use restore_mode: ALWAYS_OFF for physical control outputs.
  • Check whether the relay board is active low.
  • Add suitable pull resistors if the interface requires them.
  • Power-cycle the finished controller repeatedly before connecting it to the heating system.
  • Test OTA updates and brownout recovery while monitoring every relay.

Common Problem: Boiler Starts but No Room Heats

  • Zone relay is commanding the wrong valve.
  • Valve is powered but mechanically stuck.
  • Boiler fires before the valve is sufficiently open.
  • Circulation pump is not running.
  • Valve wiring requires a different control voltage.
  • Thermostat logic is correct but the hydraulic system is not balanced.

Verify valve movement independently before debugging Home Assistant.

Common Problem: One Zone Stops and the Boiler Turns Off

If another zone still shows heating, this almost always means the boiler relay is being controlled directly by individual thermostat actions rather than by aggregate demand.

Move the boiler output into one shared OR-based control function, as shown in this guide.

Common Problem: Temperature Oscillates Around the Setpoint

  • Increase heat_deadband slightly.
  • Increase heat_overrun if the zone cycles too rapidly.
  • Increase minimum run/off times for slow heating systems.
  • Move the sensor away from draughts, direct sunlight and radiators.
  • For underfloor heating, remember that several minutes of actuator delay and many minutes of slab thermal inertia are normal.

Common Problem: Boiler Runs with All Climate Cards Idle

  • Check the raw zone relay states.
  • Check for an inverted relay output.
  • Confirm the boiler-demand script was triggered after the last zone switched off.
  • If using valve end switches, check whether one end switch is stuck closed.
  • Verify that Home Assistant is not separately controlling the boiler relay through another automation.

Recommended Control Hierarchy

Level 1: Boiler / appliance safety controls
        ↓
Level 2: Physical valve / actuator interface
        ↓
Level 3: ESP32 local thermostat logic
        ↓
Level 4: Shared local boiler-demand aggregation
        ↓
Level 5: Home Assistant schedules / occupancy / UI

The lower levels should continue to behave sensibly even when the higher levels disappear. Home Assistant should make the system smarter, not become the only thing preventing a relay from staying on forever.

Final Recommendation

For a three- to six-zone DIY heating system, a central ESP32 running ESPHome is a strong architecture when the temperature sensors and zone wiring can reach one controller.

Give every zone its own thermostat entity and valve output, then derive the shared boiler request from the logical OR of all active zones. Keep minimum run times and hysteresis local, and let Home Assistant handle schedules, occupancy and setpoint changes.

Where motorised valves provide end switches, use them. A physical proof that a valve opened is better than a software timer. And where the heating appliance already provides certified safety controls, leave them in place rather than trying to reproduce them with an ESP32.

Related ESP32 Guides

Official Documentation

Share your love