Controlling a Marstek Venus E locally: Modbus TCP, the UDP API and why you should verify every write
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:
- “Helps”: the battery may discharge during surplus charging. So that the control loop doesn’t mistake its power for PV surplus, JG Energy subtracts the discharge power again when calculating the surplus. With a fixed electricity tariff this is energy-neutral: every kWh from the battery replaces a kWh of grid power, whether it ends up in the car or in the house in the evening.
- “Stays off”: while the car is charging from surplus, discharging is locked but charging is still allowed. So when the sun is strong, the battery keeps absorbing surplus – and its charge stays in the house for the evening.
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:
| Register | Content | Values |
|---|---|---|
44002 | maximum charging power | 0–2500 W |
44003 | maximum discharging power | 0–2500 W |
35000 | internal temperature (read) | int16, × 0.1 °C |
That gives three states:
| State | 44002 | 44003 |
|---|---|---|
| normal | 2500 | 2500 |
| charge only (“stays off”, car charging) | 2500 | 0 |
| completely off | 0 | 0 |
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:
- 400 ms pause after each access. The battery doesn’t release its connection slot immediately – without a pause, the next connection attempt fails.
- Close the connection hard and wait for it to close. In the Node.js library
modbus-serial,close()returns immediately even though the socket is still open.destroy()with a callback is more reliable.
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:
- more than 300 W of grid import for 60 seconds,
- the battery is at 14 % or more,
- according to JG Energy it isn’t locked,
- but it isn’t delivering any power.
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:
- for a running session, never for a restart,
- only up to the minimum power, not beyond,
- only while the PV is still producing something,
- only above 14 % state of charge.
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