MultiPlus-II GX in 3x230V network, integrated / controlled with Arduino ESP32

Here is full documented project, including parts.
It does work for me. I can’t promise anything for any other’s aplication.
Keep in mind that everthing below is AI generated. Use on your own responsability.

SYSTEM SPECIFICATION & PRODUCTION MANUAL

Project Target: Interfacing a Carlo Gavazzi EM540 Energy Meter with a Victron MultiPlus-II GX on a Belgian 3x230V Delta Grid (No Neutral) using an ESP32.


1. Hardware Architecture & Bill of Materials (BOM)

Component Description [1, 2] Quantity Purpose Specific Requirement / Notes
ESP32 Development Board 1 System Core Processor Standard 30-pin or 38-pin ESP32 (NodeMCU style).
DollaTek TTL to RS485 Converter 1 Bus 2 (Victron Interface) Natively compatible with 3.3V/5V logic. Contains onboard RXD/TXD diagnostic LEDs. Automatic direction switching.
Fasizi TTL to RS485 Module 1 Bus 1 (Physical Meter Interface) 5V MCU module with manual DE/RE direction switching control.
JZK USB-to-RS485 Adapter 1 Serial Media Converter Plugged into the isolated side of the ADuM3160 to bridge the RS485 differential wires.
ADuM3160 USB Voltage Isolator 1 Hardware Signal Protection Mandatory Protection. Placed between the MultiPlus-II GX USB port and the JZK adapter. Breaks ground loops up to 2500V. Must be set manually via onboard jumper to Full Speed (12Mbps).
Carlo Gavazzi EM540 1 Physical Grid Energy Meter Hard-configured to Slave ID 3 and 115200 Baud Rate.
Victron MultiPlus-II GX 1 Inverter/Charger System Core Running Venus OS, expecting Grid Meter on Slave ID 1 via USB.

2. Complete Physical Wiring Blueprint & Conditioning Networks

Bus 1: Master Loop (ESP32 ⇄ Physical EM540 Meter)

Driven via the 5V Fasizi Module with integrated hardware level-matching protection.

  • Fasizi VCC → ESP32 5V (VIN) pin
  • Fasizi GND → ESP32 GND pin
  • Fasizi DE & RE → Tied together with a jumper wire and connected directly to ESP32 GPIO 4
  • Fasizi DI (Driver In) → Connected to ESP32 GPIO 17 (TX) through a 1kΩ series conditioning resistor.
  • Fasizi RO (Receive Out) → Connected to ESP32 GPIO 16 (RX) through a 1kΩ series conditioning resistor, with a 2kΩ clamping resistor wired from the GPIO 16 side to GND (safely dividing the 5V logic output down to an ESP32-safe 3.3V threshold).
  • Fasizi Line Side (A+ / B- / GND) → Wired directly to the physical EM540 screw terminals (A+ to A+, B- to B-, GND to shared signal ground).

Bus 2: Slave Loop (ESP32 ⇄ Victron MultiPlus-II GX via Isolator)

Driven via the native 3.3V/5V buffered DollaTek Module and fully protected by galvanic isolation. [1]

  • ADuM3160 Isolator Input → Plugged straight into the Victron MultiPlus-II GX USB Port.
  • ADuM3160 Isolator Output → Connect the JZK USB-to-RS485 Adapter stick directly into this isolated socket.
  • DollaTek VCC → ESP32 3.3V pin (Forces the logic threshold to match the ESP32 natively)
  • DollaTek GND → ESP32 GND pin
  • DollaTek RXD → Connected directly to ESP32 GPIO 26 (GX_RX)
  • DollaTek TXD → Connected directly to ESP32 GPIO 27 (GX_TX)
  • DollaTek Line Side (A / B / GND) → Wired directly to the JZK Adapter terminals (A to A+, B to B-).
  • CRITICAL LINE SAFETY BRIDGING: A physical copper wire must run from the JZK GND terminal block directly back to an ESP32 GND pin to align the isolated signal reference point.

:warning: 3. System Hazards & Critical Design Warnings

USB Voltage Isolator (ADuM3160) Speed & Power Constraints

  • The Hazard: Mismatched ground potential references between the house battery banks and your microcontrollers can instantly cause high-current surges that destroy serial components. The ADuM3160 creates a protective physical magnetic barrier to resolve this.
  • The Speed Constraint: The ADuM3160 cannot auto-detect data rates. You must manually set its physical onboard jumper shunt to the “Full Speed” (12Mbps) position. If left on “Low Speed” (1.5Mbps), the Victron Venus OS architecture will fail to communicate with the JZK serial chip.
  • The Power Trap: Commercial ADuM3160 boards utilize a miniature B0505S isolated voltage regulator chip which is strictly limited to a maximum output of 1 Watt (~200mA).
  • The Structural Restriction: This regulator provides sufficient power to drive a standard JZK USB-RS485 thumb stick. However, attempting to draw power for the ESP32 core board through this isolator will instantly burn out the chip. The ESP32’s current draws peak rapidly at 300mA–400mA during processing operations, which overloads the isolator’s internal transformer.
  • Design Rule: The ESP32 must always be powered by an independent, robust power supply rail or native PC hub. Never source its active current loop through the low-power USB digital isolation chip. [1]

4. Real-Time Data Manipulation Logic (Belgian Delta Mapping)

To substitute an explicit physical neutral wire for the MultiPlus-II GX system engine, Phase L2 is treated as a Virtual Neutral (0.0V). To prevent losing the energy calculation contribution from Phase L2, its active power is split evenly between the remaining two virtual active lines monitored by the Victron ESS loop.

\(\text{Manipulated\ }V_{L1-N}=\text{True\ Line\ }V_{L1-L2}\)
\(\text{Manipulated\ }V_{L2-N}=0.0\text{V\ (Virtual\ Neutral\ Target)}\)
\(\text{Manipulated\ }V_{L3-N}=\text{True\ Line\ }V_{L3-L2}\)

\(\text{Manipulated\ }P_{L1}=\text{Real\ }P_{L1}+\left(\frac{\text{Real\ }P_{L2}}{2}\right)\)
\(\text{Manipulated\ }P_{L2}=0.0\text{W}\)
\(\text{Manipulated\ }P_{L3}=\text{Real\ }P_{L3}+\left(\frac{\text{Real\ }P_{L2}}{2}\right)\)

\(\text{Reported\ Net\ System\ Active\ Power}=\text{Real\ }P_{L1}+\text{Real\ }P_{L2}+\text{Real\ }P_{L3}\)


5. Final Verified Production Firmware Layout

#include <ModbusRTU.h> // by Alexander Emelianov
#include <HardwareSerial.h>

// =========================================================================
// HARDWARE PIN CONFIGURATIONS
// =========================================================================
#define METER_RX       16   // RO pin on Protected Fasizi Module 1
#define METER_TX       17   // DI pin on Protected Fasizi Module 1
#define METER_DE_RE     4   // RE/DE jumper pin on Fasizi Module 1
#define EM540_SLAVE_ID  3   // Physical meter set to Slave ID 3

#define GX_RX          26   // RXD pin on Direct DollaTek Module 2
#define GX_TX          27   // TXD pin on Direct DollaTek Module 2
#define VICTRON_SLAVE_ID 1  // MultiPlus expects Grid Meter on Slave ID 1

ModbusRTU mbBus1; // Master Engine (Bus 1)
ModbusRTU mbBus2; // Slave Engine (Bus 2)

HardwareSerial SerialMeter(2);
HardwareSerial SerialGX(1);

// =========================================================================
// BUS 1 HIGH-SPEED EXTRACTION BUFFER
// =========================================================================
uint16_t extLiveBlock[24]; // Holds 0x0000 to 0x0017 contiguously
uint16_t extPhasePF[3];    // 0x002E to 0x0030
uint16_t extFreqSeq[2];    // 0x0032 to 0x0033
uint16_t extEnergy[2];     // 0x0034 to 0x0035

volatile bool bus1JobDone = true;
unsigned long lastFastPoll = 0;
unsigned long lastSlowPoll = 0;
uint8_t slowState = 0;      

int32_t getInt32(uint16_t* buffer, uint8_t index) {
  return (int32_t)(((uint32_t)buffer[index + 1] << 16) | buffer[index]);
}

bool cbMasterFinished(Modbus::ResultCode event, uint16_t transactionId, void* data) {
  bus1JobDone = true; 
  return true;
}

void allocateRegisterBlock(uint16_t startAddr, uint16_t numRegs) {
  for (uint16_t i = 0; i < numRegs; i++) {
    mbBus2.addHreg(startAddr + i, 0x0000); 
    mbBus2.addIreg(startAddr + i, 0x0000); 
  }
}

void injectInt32IntoBus2(uint16_t targetAddress, int32_t value) {
  uint16_t lowWord = (uint16_t)(value & 0xFFFF);
  uint16_t highWord = (uint16_t)((value >> 16) & 0xFFFF);
  mbBus2.Hreg(targetAddress, lowWord);
  mbBus2.Ireg(targetAddress, lowWord);
  mbBus2.Hreg(targetAddress + 1, highWord);
  mbBus2.Ireg(targetAddress + 1, highWord);
}

void preLoadStaticHandshakeConstants() {
  allocateRegisterBlock(0x0000, 290); 
  allocateRegisterBlock(0x0300, 32);  
  allocateRegisterBlock(0x0530, 16);  
  allocateRegisterBlock(0x1000, 6);   
  allocateRegisterBlock(0x1100, 6);   
  allocateRegisterBlock(0x5000, 12);  

  mbBus2.Hreg(0x000B, 1760); mbBus2.Ireg(0x000B, 1760);
  mbBus2.Hreg(0x0024, 0x0554); mbBus2.Ireg(0x0024, 0x0554);
  mbBus2.Hreg(0x0026, 0x0009); mbBus2.Ireg(0x0026, 0x0009);
  mbBus2.Hreg(0x0040, 0x006F); mbBus2.Ireg(0x0040, 0x006F);
  mbBus2.Hreg(0x0044, 0x080B); mbBus2.Ireg(0x0044, 0x080B);
  mbBus2.Hreg(0x004E, 0x14B1); mbBus2.Ireg(0x004E, 0x14B1);
  mbBus2.Hreg(0x010E, 0x0000); mbBus2.Ireg(0x010E, 0x0000);
  mbBus2.Hreg(0x1002, 0x0000); mbBus2.Ireg(0x1002, 0x0000);
  mbBus2.Hreg(0x0302, 0x1403); mbBus2.Ireg(0x0302, 0x1403);

  uint16_t s1 = 0x4B58; uint16_t s2 = 0x3131; uint16_t s3 = 0x3830;
  uint16_t s4 = 0x3033; uint16_t s5 = 0x3030; uint16_t s6 = 0x3031; uint16_t s7 = 0x4100;

  mbBus2.Hreg(0x0304, s1); mbBus2.Ireg(0x0304, s1); mbBus2.Hreg(0x0305, s2); mbBus2.Ireg(0x0305, s2);
  mbBus2.Hreg(0x0306, s3); mbBus2.Ireg(0x0306, s3); mbBus2.Hreg(0x0307, s4); mbBus2.Ireg(0x0307, s4);
  mbBus2.Hreg(0x0308, s5); mbBus2.Ireg(0x0308, s5); mbBus2.Hreg(0x0309, s6); mbBus2.Ireg(0x0309, s6);
  mbBus2.Hreg(0x030A, s7); mbBus2.Ireg(0x030A, s7);

  mbBus2.Hreg(0x030B, 0x0050); mbBus2.Ireg(0x030B, 0x0050);
  mbBus2.Hreg(0x030C, 0xFEA7); mbBus2.Ireg(0x030C, 0xFEA7);
  mbBus2.Hreg(0x030D, 0x0210); mbBus2.Ireg(0x030D, 0x0210);
  mbBus2.Hreg(0x030E, 0xFFD9); mbBus2.Ireg(0x030E, 0xFFD9);
  mbBus2.Hreg(0x030F, 0x0001); mbBus2.Ireg(0x030F, 0x0001);
  mbBus2.Hreg(0x1103, 0x0001); mbBus2.Ireg(0x1103, 0x0001);

  mbBus2.Hreg(0x5000, s1); mbBus2.Ireg(0x5000, s1); mbBus2.Hreg(0x5001, s2); mbBus2.Ireg(0x5001, s2);
  mbBus2.Hreg(0x5002, s3); mbBus2.Ireg(0x5002, s3); mbBus2.Hreg(0x5003, s4); mbBus2.Ireg(0x5003, s4);
}

void refreshLiveProxyData() {
  int32_t line_V12 = getInt32(extLiveBlock, 6);  // L1-L2
  int32_t line_V23 = getInt32(extLiveBlock, 8);  // L2-L3
  int32_t line_V31 = getInt32(extLiveBlock, 10); // L3-L1

  // 1. Voltages Mapping (Phase L2 remains Virtual Neutral)
  injectInt32IntoBus2(0x0000, line_V12);  // Voltage L1-N = Line L1-L2
  injectInt32IntoBus2(0x0002, 0);         // Voltage L2-N = Forced 0.0V 
  injectInt32IntoBus2(0x0004, line_V23);  // Voltage L3-N = Line L3-L2 
  
  injectInt32IntoBus2(0x0006, line_V12);  
  injectInt32IntoBus2(0x0008, line_V23);  
  injectInt32IntoBus2(0x000A, line_V31);  
  mbBus2.Hreg(0x000B, 1760); mbBus2.Ireg(0x000B, 1760);

  // 2. Real Currents Pass-Through
  injectInt32IntoBus2(0x000C, getInt32(extLiveBlock, 12)); 
  injectInt32IntoBus2(0x000E, getInt32(extLiveBlock, 14)); 
  injectInt32IntoBus2(0x0010, getInt32(extLiveBlock, 16)); 

  // 3. HARDENED BALANCING MATHEMATICS (Phase 2 Power Allocation)
  int32_t real_P1 = getInt32(extLiveBlock, 18); 
  int32_t real_P2 = getInt32(extLiveBlock, 20); 
  int32_t real_P3 = getInt32(extLiveBlock, 22); 
  
  int32_t manipulated_P1 = real_P1 + (real_P2 / 2);
  int32_t manipulated_P3 = real_P3 + (real_P2 / 2);

  injectInt32IntoBus2(0x0012, manipulated_P1); // Virtualized L1 Power
  injectInt32IntoBus2(0x0014, 0);               // Forced L2 Power to 0W
  injectInt32IntoBus2(0x0016, manipulated_P3); // Virtualized L3 Power

  // 4. TOTAL ACTIVE POWER RECONCILIATION
  int32_t trueTotalSystemPower = real_P1 + real_P2 + real_P3;
  injectInt32IntoBus2(0x0028, trueTotalSystemPower);          

  // 5. Secondary Grid Context
  mbBus2.Hreg(0x002E, extPhasePF[0]); mbBus2.Ireg(0x002E, extPhasePF[0]); // L1 PF
  mbBus2.Hreg(0x002F, extPhasePF[1]); mbBus2.Ireg(0x002F, extPhasePF[1]); // L2 PF
  mbBus2.Hreg(0x0030, extPhasePF[2]); mbBus2.Ireg(0x0030, extPhasePF[2]); // L3 PF

  mbBus2.Hreg(0x0032, extFreqSeq[0]); mbBus2.Ireg(0x0032, extFreqSeq[0]); // Sequence
  mbBus2.Hreg(0x0033, extFreqSeq[1]); mbBus2.Ireg(0x0033, extFreqSeq[1]); // Grid Frequency

  injectInt32IntoBus2(0x0034, getInt32(extEnergy, 0));    
}

void setup() {
  Serial.begin(115200);
  while (!Serial);

  // Initialize both lines at their specific target rates
  SerialMeter.begin(115200, SERIAL_8N1, METER_RX, METER_TX); // Bus 1 Turbo Rate
  SerialGX.begin(9600, SERIAL_8N1, GX_RX, GX_TX);            // Bus 2 Victron Rate

  mbBus1.begin(&SerialMeter, METER_DE_RE);
  mbBus1.master();

  mbBus2.begin(&SerialGX, -1); 
  mbBus2.slave(VICTRON_SLAVE_ID);

  preLoadStaticHandshakeConstants();
  Serial.println("[SYSTEM ONLINE] Ultra-Speed Delta Balancing Proxy Engaged.");
}

void loop() {
  mbBus1.task();
  mbBus2.task();

  // TURBO FAST LOOP (Every 50ms)
  if (bus1JobDone && (millis() - lastFastPoll > 50)) {
    lastFastPoll = millis();
    bus1JobDone = false;
    mbBus1.readIreg(EM540_SLAVE_ID, 0x0000, extLiveBlock, 24, cbMasterFinished);
    refreshLiveProxyData(); 
  }

  // BACKGROUND CONTEXT LOOP (Every 2000ms) - UPDATED FOR 5 MODBUS CYCLES
  if (bus1JobDone && (millis() - lastSlowPoll > 2000)) {
    lastSlowPoll = millis();
    bus1JobDone = false;

    switch (slowState) {
      case 0: mbBus1.readIreg(EM540_SLAVE_ID, 0x002E, extPhasePF, 3, cbMasterFinished); break;
      case 1: mbBus1.readIreg(EM540_SLAVE_ID, 0x0032, extFreqSeq, 2, cbMasterFinished); break;
      case 2: mbBus1.readIreg(EM540_SLAVE_ID, 0x0034, extEnergy, 2, cbMasterFinished); break; // kWh (+) TOT
      case 3: mbBus1.readIreg(EM540_SLAVE_ID, 0x0040, extPhaseEnergy, 6, cbMasterFinished); break; // L1, L2, L3 kWh (+)
      case 4: mbBus1.readIreg(EM540_SLAVE_ID, 0x004E, extNegEnergy, 2, cbMasterFinished); break;   // kWh (-) TOT
    }
    slowState = (slowState + 1) % 5; 
  }
}

Use code with caution. Software is AI generated.

Do not forget USB to 485 cable. This one works directly Multiplus - EM540, but does not manipulate the values. It can be used for fast start of the system.
USB Cable RS485 Compatible for Gerbox GX EM540 Grid Meter EM112 Victron ET112 Wired Connection FT232RNL RS485 Serial Converter Cable Adapter