How to connect to IMMERGAS Magis Combo V2 / Magis Pro V2 heat pump via MODBUS into HA

Hello @Kludu, I have the exact same issue, but fortunately I still have one remote panel connected, which allows me to keep the heating running while using T+T-.

I tried several times to work around this by playing with register values and parameters, but I wasn’t able to solve it.

My only concern with using D+D- is that if the device connected to those terminals is not working (for example during maintenance or a software update), the boiler might raise an error.

I’m waiting for warmer temperatures so I can finally disconnect the remote panel and try to reverse‑engineer the Modbus registers. If that works, I’m planning to develop my own firmware and integrate it with Home Assistant via MQTT. I can’t make any promises yet.

Hi Kludu,
Do you mean this?

Actually i’m using T+/T- and M5Stack AtomS3 Lite + 485 and the switch from heating to Off or only DHW works perfetly. The samsung nasa also works but only for understand some sensors. In the D+/D- have a Remote panel immergas (Cod. 3.030863) as the figure below:

Could you give a diagram of your connection, only for better undertand and the model of your Boiler. In the 40-1 /41 i’ve a Bticino smather , but it’s only a simple relais. Today don’t have much time to make a diagram, I’ll try to make in the next days. Sorry :frowning:

Thanks for answer.
I’ve Immergas Magis Combo 12 V2 T
The parameter that you show on screenshot from HA eneble to change work mode on interior unit from Summer (only DHW), Winter (DHW and Heating) and Cooling (which I’ve never use).
When I use T+ and T- I could also change this parameter, but on 40-1 and 41 i have wire as on diagram that you show, so always when it’s on Winter/Heating mode unit should start heating, but, when I was on T+/T- it’s not working like this…

Now I try to connect to D+/D- for few days and start to emulate orignal panel from Immergas but I need to me sleve on modbus - so it’s not easy work.
I work with Claude AI, for now I have DHW temperature and parameter to SET DHW temp. I have also information which work mode is (winter/summer/cooling or standby) and sensor for information if unit works or not.
Next step will be to try change work mode and to enable heating, but i need more time :slight_smile:
I will keep you updated.

Rgrds

1 Like

Dears, unfortunatelly I don't have enought time last few weeks... I would like to share with you what I have till now, maybe someone will add something more.

I'm using Atom5 Lite and RS485 connected to D+/D- and A31 set as RP.

Functions:

  • setting DHW temp
  • changing mode (Summer[Only DHW], Winter[DHW+heating] or standby)
  • binary sensor about working or no-working of pump
  • sensor with actual mode
  • sensor with actual DHW temp in tank
  • sensor with actual temp outside (my pump have a temp sensor in outdoor unit)

Full code was done with cloude ai and all translations are in polish - I think that with cloude you can easily translate it.

Next what I want is to try eneble heating, but for now it works with my sensor Tybox 137+.

esphome:

name: esphome-web-526b30

friendly_name: Pompa Ciepła_ESPHomeB

min_version: 2025.11.0

name_add_mac_suffix: false

esp32:

variant: esp32

framework:

type: esp-idf

logger:

baud_rate: 0

level: DEBUG

api:

ota:

  • platform: esphome

wifi:

ssid: !secret wifi_ssid

password: !secret wifi_password

uart:

id: modbus_uart

tx_pin: GPIO19

rx_pin: GPIO22

baud_rate: 9600

parity: EVEN

stop_bits: 1

data_bits: 8

flow_control_pin: GPIO23

globals:

  • id: cwu_setpoint

    type: int

    restore_value: yes

    initial_value: '450'

  • id: temp_cwu_zbiornik

    type: int

    restore_value: no

    initial_value: '0'

  • id: temp_zewnetrzna

    type: int

    restore_value: no

    initial_value: '0'

  • id: heating_request

    type: int

    restore_value: no

    initial_value: '0'

  • id: operating_mode

    type: int

    restore_value: no

    initial_value: '1'

sensor:

  • platform: template

    name: "Temperatura CWU"

    id: temp_cwu_sensor

    device_class: temperature

    state_class: measurement

    unit_of_measurement: "°C"

    accuracy_decimals: 1

    lambda: |-

    if (id(temp_cwu_zbiornik) == 0) return {};

    return id(temp_cwu_zbiornik) / 10.0;

    update_interval: 2s

  • platform: template

    name: "Temperatura zewnętrzna"

    id: temp_zewnetrzna_sensor

    device_class: temperature

    state_class: measurement

    unit_of_measurement: "°C"

    accuracy_decimals: 1

    lambda: |-

    if (id(temp_zewnetrzna) == 0) return {};

    return id(temp_zewnetrzna) / 10.0;

    update_interval: 2s

text_sensor:

  • platform: template

    name: "Tryb pracy"

    id: operating_mode_text

    icon: "mdi:home-thermometer"

    lambda: |-

    if (id(operating_mode) == 0) return {"Standby/Wyłączony"};

    if (id(operating_mode) == 1) return {"Lato (CWU)"};

    if (id(operating_mode) == 3) return {"Zima (CO+CWU)"};

    return {"Nieznany"};

    update_interval: 2s

binary_sensor:

  • platform: template

    name: "Urządzenie pracuje"

    id: urzadzenie_pracuje

    device_class: running

    lambda: |-

    return id(heating_request) == 1;

number:

  • platform: template

    name: "CWU Temperatura zadana"

    id: cwu_setpoint_number

    min_value: 10

    max_value: 65

    step: 1

    unit_of_measurement: "°C"

    device_class: temperature

    mode: slider

    icon: "mdi:water-thermometer"

    lambda: |-

    return id(cwu_setpoint) / 10.0;

    set_action:

    lambda: |-

    id(cwu_setpoint) = x \* 10;
    
    ESP_LOGW("cwu_setpoint", "🎯 Ustawiono CWU = %.1f°C (%d)", x, id(cwu_setpoint));
    

select:

  • platform: template

    name: "Tryb pracy pompy"

    id: operating_mode_select

    icon: "mdi:state-machine"

    options:

    • "Standby"

    • "Lato (CWU)"

    • "Zima (CO+CWU)"

    lambda: |-

    if (id(operating_mode) == 0) return {"Standby"};

    if (id(operating_mode) == 1) return {"Lato (CWU)"};

    if (id(operating_mode) == 3) return {"Zima (CO+CWU)"};

    return {"Lato (CWU)"};

    update_interval: 2s

    set_action:

    • lambda: |-

      if (x == "Standby") {

      id(operating_mode) = 0;
      
      ESP_LOGW("mode_request", "⚙️ Zmiana trybu: STANDBY (0)");
      

      } else if (x == "Lato (CWU)") {

      id(operating_mode) = 1;
      
      ESP_LOGW("mode_request", "⚙️ Zmiana trybu: LATO (1)");
      

      } else if (x == "Zima (CO+CWU)") {

      id(operating_mode) = 3;
      
      ESP_LOGW("mode_request", "⚙️ Zmiana trybu: ZIMA (3)");
      

      }

interval:

  • interval: 10ms

    then:

    • lambda: |-

      static std::vector<uint8_t> buffer;

      static std::map<uint16_t, uint16_t> registers;

      static std::map<uint16_t, uint16_t> prev_registers;

      static uint32_t last_byte_time = 0;

      uint32_t now = millis();

      while (id(modbus_uart).available()) {

      uint8_t byte;
      
      id(modbus_uart).read_byte(&byte);
      
      buffer.push_back(byte);
      
      last_byte_time = now;
      

      }

      if (!buffer.empty() && (now - last_byte_time) > 5) {

      if (buffer.size() >= 8) {
      
        uint8_t addr = buffer\[0\];
      
        uint8_t func = buffer\[1\];
      
      
      
        if (addr == 0x29) {
      
      
      
          // ================= READ =================
      
          if (func == 0x03 && buffer.size() >= 8) {
      
            uint16_t start = (buffer\[2\] << 8) | buffer\[3\];
      
            uint16_t count = (buffer\[4\] << 8) | buffer\[5\];
      
      
      
            ESP_LOGD("modbus_read", "READ: start=0x%04X count=%d", start, count);
      
      
      
            std::vector<uint8_t> resp;
      
            resp.push_back(0x29);
      
            resp.push_back(0x03);
      
            resp.push_back(count \* 2);
      
      
      
            for (int i = 0; i < count; i++) {
      
              uint16_t reg = start + i;
      
              uint16_t val = registers.count(reg) ? registers\[reg\] : 0;
      
      
      
              if (reg == 0x082F) {
      
                val = id(cwu_setpoint);
      
                ESP_LOGW("override", "📤 READ 0x082F (CWU setpoint) = %d (%.1f°C)", val, val/10.0);
      
              }
      
              
      
              if (reg == 0x07D0) {
      
                val = id(operating_mode);
      
                ESP_LOGW("override", "📤 READ 0x07D0 (tryb pracy) = %d", val);
      
              }
      
      
      
              resp.push_back((val >> 8) & 0xFF);
      
              resp.push_back(val & 0xFF);
      
            }
      
      
      
            uint16_t crc = 0xFFFF;
      
            for (auto b : resp) {
      
              crc ^= b;
      
              for (int i = 0; i < 8; i++) {
      
                if (crc & 1)
      
                  crc = (crc >> 1) ^ 0xA001;
      
                else
      
                  crc >>= 1;
      
              }
      
            }
      
      
      
            resp.push_back(crc & 0xFF);
      
            resp.push_back((crc >> 8) & 0xFF);
      
      
      
            id(modbus_uart).write_array(resp.data(), resp.size());
      
            id(modbus_uart).flush();
      
          }
      
      
      
          // ================= WRITE SINGLE =================
      
          else if (func == 0x06 && buffer.size() >= 8) {
      
            uint16_t reg = (buffer\[2\] << 8) | buffer\[3\];
      
            uint16_t val = (buffer\[4\] << 8) | buffer\[5\];
      
      
      
            bool changed = !prev_registers.count(reg) || prev_registers\[reg\] != val;
      
      
      
            if (changed) {
      
              if (reg == 0x07D0) {
      
                id(operating_mode) = val;
      
                ESP_LOGW("change", "⚡ 0x%04X = %d \[TRYB PRACY\]", reg, val);
      
              } else if (reg == 0x07D1) {
      
                id(heating_request) = val;
      
                ESP_LOGW("change", "⚡ 0x%04X = %d \[PRACA URZĄDZENIA\]", reg, val);
      
              } else if (reg == 0x08F4) {
      
                id(cwu_setpoint) = val;
      
                auto call = id(cwu_setpoint_number).make_call();
      
                call.set_value(val / 10.0);
      
                call.perform();
      
                ESP_LOGW("change", "⚡ 0x%04X = %d (%.1f°C) \[CWU SETPOINT\]", reg, val, val/10.0);
      
              } else {
      
                ESP_LOGW("change", "⚡ 0x%04X = %d", reg, val);
      
              }
      
            }
      
      
      
            prev_registers\[reg\] = val;
      
            registers\[reg\] = val;
      
      
      
            id(modbus_uart).write_array(buffer.data(), 8);
      
            id(modbus_uart).flush();
      
          }
      
      
      
          // ================= WRITE MULTI =================
      
          else if (func == 0x10 && buffer.size() >= 9) {
      
            uint16_t start = (buffer\[2\] << 8) | buffer\[3\];
      
            uint16_t count = (buffer\[4\] << 8) | buffer\[5\];
      
      
      
            bool any_change = false;
      
            
      
            for (uint16_t i = 0; i < count; i++) {
      
              uint16_t hi_index = 7 + (i \* 2);
      
              uint16_t lo_index = 8 + (i \* 2);
      
              if (lo_index < buffer.size()) {
      
                uint16_t val = (buffer\[hi_index\] << 8) | buffer\[lo_index\];
      
                uint16_t reg = start + i;
      
                
      
                if (!prev_registers.count(reg) || prev_registers\[reg\] != val) {
      
                  any_change = true;
      
                  break;
      
                }
      
              }
      
            }
      
      
      
            if (any_change) {
      
              ESP_LOGW("change", "⚡ WRITE MULTI: start=0x%04X count=%d", start, count);
      
            }
      
      
      
            for (uint16_t i = 0; i < count; i++) {
      
              uint16_t hi_index = 7 + (i \* 2);
      
              uint16_t lo_index = 8 + (i \* 2);
      
              if (lo_index < buffer.size()) {
      
                uint16_t val = (buffer\[hi_index\] << 8) | buffer\[lo_index\];
      
                uint16_t reg = start + i;
      
      
      
                bool changed = !prev_registers.count(reg) || prev_registers\[reg\] != val;
      
      
      
                if (changed) {
      
                  if (reg == 0x0BC8) {
      
                    id(temp_cwu_zbiornik) = val;
      
                    ESP_LOGW("change", "  ⚡ 0x%04X = %d (%.1f°C) \[TEMP CWU\]", reg, val, val/10.0);
      
                  } else if (reg == 0x0BBA) {
      
                    id(temp_zewnetrzna) = val;
      
                    ESP_LOGW("change", "  ⚡ 0x%04X = %d (%.1f°C) \[TEMP ZEWNĘTRZNA\]", reg, val, val/10.0);
      
                  } else if (reg == 0x07D0) {
      
                    id(operating_mode) = val;
      
                    ESP_LOGW("change", "  ⚡ 0x%04X = %d \[TRYB PRACY\]", reg, val);
      
                  } else if (reg == 0x07D1) {
      
                    id(heating_request) = val;
      
                    ESP_LOGW("change", "  ⚡ 0x%04X = %d \[PRACA URZĄDZENIA\]", reg, val);
      
                  } else if (reg == 0x08F4) {
      
                    id(cwu_setpoint) = val;
      
                    auto call = id(cwu_setpoint_number).make_call();
      
                    call.set_value(val / 10.0);
      
                    call.perform();
      
                    ESP_LOGW("change", "  ⚡ 0x%04X = %d (%.1f°C) \[CWU SETPOINT\]", reg, val, val/10.0);
      
                  } else {
      
                    ESP_LOGW("change", "  ⚡ 0x%04X = %d", reg, val);
      
                  }
      
                }
      
      
      
                prev_registers\[reg\] = val;
      
                registers\[reg\] = val;
      
              }
      
            }
      
      
      
            std::vector<uint8_t> resp;
      
            resp.push_back(0x29);
      
            resp.push_back(0x10);
      
            resp.push_back(buffer\[2\]);
      
            resp.push_back(buffer\[3\]);
      
            resp.push_back(buffer\[4\]);
      
            resp.push_back(buffer\[5\]);
      
      
      
            uint16_t crc = 0xFFFF;
      
            for (auto b : resp) {
      
              crc ^= b;
      
              for (int i = 0; i < 8; i++) {
      
                if (crc & 1)
      
                  crc = (crc >> 1) ^ 0xA001;
      
                else
      
                  crc >>= 1;
      
              }
      
            }
      
      
      
            resp.push_back(crc & 0xFF);
      
            resp.push_back((crc >> 8) & 0xFF);
      
      
      
            id(modbus_uart).write_array(resp.data(), resp.size());
      
            id(modbus_uart).flush();
      
          }
      
        }
      
      }
      
      
      
      buffer.clear();
      

      }

I had some time today to observe an issue related to toggling Zone 1 and Zone 2. Some strange behaviour: when modbus device is connected to T+/T-, you can't just toggle Zone 1 via relay. You still can toggle Zone 2.

I needed to test some addresses, because I need to toggle Zone 1. As of now I've never needed it and always used Zone 2 in the current installation. Didn't even expected the limitation set by modbus comm. Anyway ... found a solution :slight_smile:

@Kludu @Bettapro @Andysate @JoTu
Using register 2010 as a modbus switch - Zone 1 can be toggled via relay or room thermostat when this switch is ON.

Hi Kludu,

First of all, great work. I really like the direction you took with the D+/D- bus. It looks much more elegant than trying to force another master onto this bus.

If I understand your code correctly, your Atom/ESP is not working as a Modbus master, but rather as a slave device emulating the zone panel, probably responding on address "0x29" / "41". That would also explain why your communication seems stable — the Immergas unit remains the master on D+/D-, and the ESP only replies when it is queried, just like the original Zone 1 panel would do.

This makes a lot of sense to me.

I have one question about Zone 1 room temperature. When the original Zone 1 panel is replaced by the Atom/ESP on D+/D-, the system no longer has the real room temperature measurement from the physical panel. How do you handle this in your setup?

Do you:

  • send a fixed/dummy room temperature value from the ESP,
  • use an external temperature sensor,
  • pass a temperature value from Home Assistant,
  • or do you rely completely on your Tybox thermostat for room temperature and heating demand?

I am asking because in my setup I use multiple room thermostats with Salus wiring centers, so I am considering whether the Zone 1 panel temperature is even needed, or whether it is better to let the room thermostats handle the real heating demand.

Hi v.k,

Thank you very much for sharing this discovery about register "2010".

This is definitely worth testing, because it may be the missing piece for integrating the Immergas heat pump with external room thermostats or wiring centers, for example Salus.

Until now, it looked like when Modbus is connected on the T+/T- bus, Zone 1 no longer reacts properly to the normal thermostat/relay input. If setting register "2010" to ON really allows Zone 1 to be controlled again by an external thermostat, this could be a kind of “holy grail” for many installations.

In my case I use multiple room thermostats with Salus wiring centers, so I do not necessarily need the Immergas Zone 1 panel to measure room temperature. I only need the heat pump to accept the heating demand from the external thermostats.

I will definitely test this. It may greatly simplify the whole integration architecture.

I have tested this on my installation.

With the T+/T- Modbus bus active and the Zone 1 panel connected on D+/D-, heating demand for Zone 1 can be controlled via terminals 40-41, but only after changing parameter A31 from RP to RPT.

In my case:

  • A31 = RP: the Zone 1 panel is active, but terminals 40-41 do not trigger heating demand.
  • A31 = RPT: the Zone 1 panel remains active, and terminals 40-41 immediately trigger heating demand.

So for my setup, with the Zone 1 panel still connected, the key setting is A31 = RPT. PDU 2010 does not seem to be involved in this behaviour. Even with PDU 2010 = 0, terminals 40-41 work correctly when A31 is set to RPT.

In your use case you have connected Zone 1 remote panel to D+/D-. Setting A31 to RPT (remote panel + external thermostat) allows the Magis Pro/Combo to communicate with this panel and allows toggling by external room thermostat. If you disconnect the panel - an error is shown on the display and the heat generator (magis pro or combo) is blocked to inactive state. If you disconnect the Zone 1 panel from D+/D- and change A31 back to RT (remote thermostat) and not RP (remote panel), then changing 2010 = 1 will allow the magis pro/combo Zone 1 to be toggled via 41 and 40-1.

Changing A31 to RP will allow Zone 1 toggling only by remote panel and never by remote thermostat.

There are 3 values for A31, A32, A33 as per manual:
RP = remote panel
RPT = remote panel + thermostat
RT = remote thermostat

2010 = 1 and A31 = RT - this combination will allow the Zone 1 to be toggled by external ON/OFF thermostat, without using remote panel at D+/D-.
This exact use case I've tested and is working as expected.

I did two additional tests on my installation and the results are quite interesting.

My current configuration is:

  • Zone 1 remote panel connected to D+/D-
  • T+/T- Modbus bus active
  • A31 set to RPT
  • Salus wiring centers connected to terminals 40-41 as an external ON/OFF thermostat
  • PDU 2010 left OFF / 0

Test 1:

I set the Zone 1 temperature in Dominus / remote panel to 25°C. Then I opened the Salus contact, so terminals 40-41 were not shorted.

Result: heating did not start. Home Assistant showed the boiler and heat pump as standby.

So even if Dominus / the Zone 1 panel requests a high temperature, the heat generator does not start when the external thermostat contact 40-41 is open.

Test 2:

I set the Zone 1 temperature in Dominus / remote panel to 21°C. The measured Zone 1 temperature was around 23.5°C, so according to the remote panel there should be no heating demand.

Then I set one Salus room thermostat to 25°C. The Salus wiring center closed the 40-41 contact.

Result: after a few minutes the heat pump started heating.

So in my setup, with A31 = RPT, the 40-41 contact from the external thermostat / Salus wiring center is enough to start heating, even if the Zone 1 remote panel setpoint is lower than the current Zone 1 temperature.

This means that, in RPT mode, the Zone 1 panel remains connected and available, but the real heating demand can be controlled by the external thermostat input 40-41. In my case, PDU 2010 is not required for this scenario.

v.k’s discovery is still very important, because it gives a real possibility to remove the Zone 1 remote panel from the installation. If I understand correctly, with the remote panel disconnected, A31 set to RT, and PDU 2010 set to 1, Zone 1 can be controlled by an external ON/OFF thermostat via 40-41 without using the remote panel on D+/D-.

So we now seem to have two possible architectures:

  1. Keep the Zone 1 remote panel:

    • A31 = RPT
    • external thermostat / Salus connected to 40-41
    • PDU 2010 not needed
  2. Remove the Zone 1 remote panel:

    • A31 = RT
    • external thermostat connected to 40-41
    • PDU 2010 = 1 required

For my installation, the first option is already confirmed and working.

The remaining interesting part is the D+/D- bus. Dominus and the remote panel can change the heating curve / curve offset, so the related register or PDU should also be discoverable on D+/D-. If we can identify the PDU responsible for changing the heating curve, then Home Assistant integration will become much more complete.

1 Like

We probably already have a candidate for heating curve offset from the decoded Dominus app: PDU 2102. It still needs to be confirmed on D+/D- and tested whether writing this value works reliably.

  - name: "Immergas - Offset krzywej grzewczej"
    address: 2102
    input_type: holding
    data_type: int16
    min_value: -10
    max_value: 10
    step: 1
    mode: slider
    write_type: holding
    scan_interval: 30