Controlling a Marstek Venus E locally: Modbus TCP, the UDP API and why you should verify every write

· 6 min read · Also in Deutsch

The Marstek Venus E is an affordable plug-in home battery with 5.12 kWh. It regulates itself: a smart meter at the grid connection – a Shelly Pro 3EM in my case – tells it whether power is currently being imported or exported. The battery then charges or discharges so that the meter reads as close to zero as possible.

That works well as long as the battery is the only “smart” consumer. As soon as a wallbox enters the picture, things get complicated. So my energy management system JG Energy steps into the control loop locally – not with its own power control, but with two simple limits.

The problem: the battery charges the car

The battery only sees the grid meter. When the wallbox starts pulling 11 kW, the battery suddenly sees grid import – and discharges at full power to compensate. So the home battery ends up charging the car.

Whether that’s what you want depends on the situation. JG Energy has a setting for this on the wallbox page, “home battery while the car charges”, with two options:

The setting applies to surplus charging. When the car charges at full power in fast mode, the battery behaves completely normally. It can’t be switched during a running session, so the registers don’t change halfway through.

Two interfaces: UDP for reading, Modbus for writing

The Venus E 3.0 offers two ways in on the local network. Depending on the firmware, the local interface may first have to be enabled in the Marstek app.

Reading via the UDP API (port 30000). This is simple JSON-RPC over UDP:

{ "id": 1, "method": "ES.GetStatus", "params": { "id": 0 } }

Among other things, the response contains bat_soc (state of charge), bat_cap (capacity), ongrid_power (current power, positive = discharging) and counters for energy charged and delivered. JG Energy polls this continuously.

Writing via Modbus TCP (port 502, unit ID 1). Two holding registers are all I need for control:

RegisterContentValues
44002maximum charging power0–2500 W
44003maximum discharging power0–2500 W
35000internal temperature (read)int16, × 0.1 °C

That gives three states:

State4400244003
normal25002500
charge only (“stays off”, car charging)25000
completely off00

Note: generation 3 uses different register addresses than older Venus models. Guides for the earlier generations of the Venus E don’t apply.

Pitfall #1: only a single TCP connection

The Marstek allows exactly one Modbus TCP connection at a time. If a second function connects while the first is still connected, you get ECONNREFUSED.

In a larger program this happens sooner than you’d think: in my case the temperature poll, the wallbox’s discharge lock and diagnostic endpoints all ran independently of each other. Occasionally a write got lost along the way.

The solution is a queue per device: all access to the Marstek happens one after the other. Plus two details:

One more small thing in modbus-serial: set the timeout with setTimeout() before connectTCP(). Otherwise even the connection attempt can hang indefinitely.

Pitfall #2: acknowledged doesn’t mean executed

The second quirk was trickier: the Marstek occasionally acknowledges writes without executing them. The Modbus response is positive, but the register still holds the old value.

This is particularly nasty for register 44003. If the release after a charging session gets lost, discharging stays locked. The battery then only charges and never discharges – and you only notice when you wonder why grid power is flowing in the evening although the battery is full.

So JG Energy now writes and then reads back over the same connection. If the value doesn’t match, it writes once more. If the mismatch persists, the function reports an error:

const ModbusRTU = require('modbus-serial');

async function writeAndVerify(ip, pairs) {        // pairs: [[44003, 0], ...]
  const client = new ModbusRTU();
  client.setTimeout(3000);                         // before connectTCP!
  await client.connectTCP(ip, { port: 502 });
  client.setID(1);
  try {
    for (let attempt = 1; attempt <= 2; attempt++) {
      for (const [reg, value] of pairs) await client.writeRegister(reg, value);
      const wrong = [];
      for (const [reg, value] of pairs) {
        const r = await client.readHoldingRegisters(reg, 1);
        if (r.data[0] !== value) wrong.push(reg);
      }
      if (!wrong.length) return true;
    }
    return false;
  } finally {
    await new Promise(ok => client.destroy(ok));
  }
}

Pitfall #3: when the battery still does nothing

Even with verification, the battery may not discharge when it should. So a small watchdog runs on top:

The watchdog then reads the registers directly from the device and logs the finding: if 44003 is 0, the lock got stuck in the device. If the registers allow discharging, the battery is limiting itself – for example because of the discharge limit set in the app. That way the log tells you right away where to look.

The battery as a bridge

With the setting on “helps”, JG Energy goes one step further. If during surplus charging the PV power drops briefly below the wallbox’s minimum (6 A, i.e. 1.4 or 4.1 kW), charging would have to pause. The battery may then fill the gap deliberately – but only:

The result: far fewer on/off cycles at the wallbox.

Conclusion

Two registers are enough to make the Marstek work sensibly alongside a wallbox. The real work is in reliability: one connection at a time, read back every write, and monitor during operation whether the device does what it should.

One gap remains with “stays off”: if the server fails while discharging is locked, it stays locked until the server is back. Unlike the wallbox, the Marstek has no lock with an expiry time. For me that’s a rare case I’ve decided to accept.

Links