Loading...
Author Cloudapp
E.G.

Modbus Write-Through: Control Battery & Inverter Through a Caching Proxy in Home Assistant

June 30, 2026
Table of Contents

My Huawei SDongle allows exactly one Modbus connection at a time. That's why I run a small caching proxy in front of it that holds the connection exclusively and answers all reads from cache — I described it in detail in the caching proxy post. It works beautifully as long as you only read. Then Tibber came into the picture, I wanted to force-charge the battery on negative electricity prices and set the export limit dynamically — and suddenly I needed writes.

Here's the non-obvious catch: a cache only solves reads. A write has no business sitting in a cache — it has to actually reach the inverter, otherwise nothing changes physically. So the proxy needs a special case: read from cache, pass writes straight through, and do it all without breaking the single-connection invariant. That write-through path is exactly what I'll show here.

Why a write must not go into the cache

The caching proxy gets all its mileage from the fact that read registers change slowly — battery level, string currents, daily yield. Those you can happily cache for a few seconds and serve to several clients at once. A write command is the exact opposite: it's a side effect, not a value. "Set register 47075 to forced-charge mode" has to land on the device, or the command was a no-op.

Modbus distinguishes this cleanly via the function code. Reads are FC3 (Read Holding Registers) or FC4 — those go into the cache. The classic for writing a single register is FC6 (Write Single Register), and that's exactly what the proxy intercepts and handles differently: pass through instead of cache.

Detecting and forwarding FC6

In the proxy's request handler I check the function code from the PDU. If it's a 6 and the PDU is long enough, I unpack the register address and value and forward the write straight to the SDongle. If it succeeds, I echo the standard write response (FC6 replies with the address and value mirrored back). If it fails, I return a proper Modbus exception instead of leaving the client hanging:

elif fc == 6 and len(pdu) >= 5:
    reg_addr, reg_value = struct.unpack(">HH", pdu[1:5])
    write_ok = await forward_write(tx_id, unit_id, reg_addr, reg_value)
    if write_ok:
        resp_pdu = struct.pack(">BHH", fc, reg_addr, reg_value)  # echo
    else:
        resp_pdu = struct.pack(">BB", fc + 0x80, 4)  # 0x80|fc, code 4 = device failure
    resp_header = struct.pack(">HHHB", tx_id, 0, len(resp_pdu) + 1, unit_id)
    client_writer.write(resp_header + resp_pdu)

Two details that matter. The transaction ID (tx_id) from the MBAP header I return unchanged — the client matches responses by it, and a wrong value leads to timeouts. And the error response follows the Modbus convention: function code with the top bit set (fc + 0x80) plus an exception code; the 4 means "Slave Device Failure". That way Home Assistant knows the write attempt failed instead of blindly waiting for a reply.

Forwarding the write on a short-lived connection

Now the core that preserves the single-connection invariant. The proxy keeps its one persistent connection for the polling loop. For a write I open a separate, short-lived connection to the SDongle, send exactly one FC6 frame, read the reply and close again immediately. Because the write is so brief, it practically never collides with the poll cycle — and if it does, this is the spot where a mutex around SDongle access belongs (more on that in a later post).

# SDONGLE_HOST / SDONGLE_PORT kommen aus deiner Config, z.B.
# SDONGLE_HOST = "10.0.0.x"   # dein Wechselrichter SDongle
# SDONGLE_PORT = 502

async def forward_write(tx_id, unit_id, reg_addr, reg_value):
    reader, writer = await asyncio.wait_for(
        asyncio.open_connection(SDONGLE_HOST, SDONGLE_PORT), timeout=10)
    req = struct.pack(">HHHBBHH", 0, 0, 6, unit_id, 6, reg_addr, reg_value)
    writer.write(req)
    await writer.drain()
    resp = await asyncio.wait_for(reader.read(12), timeout=10)
    writer.close()
    return len(resp) >= 12

The struct.pack(">HHHBBHH", ...) builds a complete Modbus TCP frame: MBAP header (transaction ID 0, protocol ID 0, length 6), then unit ID, function code 6, register address and value. The 10-second timeouts are generous — an SDongle normally answers a write in milliseconds. A valid FC6 response is 12 bytes; if I get fewer, the write counts as failed and the caller above sends the exception.

Rejecting unknown function codes cleanly

There are more function codes than FC3, FC4 and FC6 — FC16 (Write Multiple Registers), FC1, FC2 and so on. When a client sends a code the proxy doesn't implement, the worst reaction is to not reply at all. Then the client blocks until timeout and the whole poll loop stalls. The right answer is the Modbus exception "Illegal Function" (code 1) — the client knows immediately and moves on:

else:
    resp_pdu = struct.pack(">BB", fc + 0x80, 1)  # 0x80|fc, code 1 = illegal function
    resp_header = struct.pack(">HHHB", tx_id, 0, len(resp_pdu) + 1, unit_id)
    client_writer.write(resp_header + resp_pdu)

It's the same exception mechanic as the device-failure case, just with code 1 instead of 4. A well-behaved Modbus proxy never leaves a request unanswered — either the real result comes back or a defined error, but never silence.

What this actually lets you control

With the write-through path I control the battery directly from Home Assistant automations. The most interesting use is the dynamic tariff: when the Tibber price drops below a threshold I write the forced-charge mode, and when it rises again I switch back to self-consumption. To see whether the command actually took, I watch sensors like sensor.my_pv_batteriestand plus sensor.batterie_gesamt_ladung and sensor.batterie_gesamt_entladung. How to measure self-consumption and autarky cleanly is covered in the self-consumption post.

Important: write registers and their allowed values are strictly vendor-specific and partly dangerous (you can put a system into a nonsensical state). Take the concepts here, but look up the exact registers and permitted values in your own inverter's Modbus document — and test with harmless registers before flipping charge modes.

Frequently asked questions

Why not just cache writes too?

Because a write isn't a value, it's a command with a physical side effect. "Writing into the cache" would mean the command never reaches the device — the battery doesn't charge, the power limit doesn't change. Reads can be cached because they're idempotent; writes always have to be passed through.

Why a separate connection for the write — don't I already have one open?

The persistent connection is occupied by the polling loop. A short-lived extra connection just for the write keeps the logic simple and the write frame out of the read-cache path. Because the write is done in milliseconds, the SDongle's "one connection at a time" rule stays practically intact. For real parallel access, a mutex belongs at this spot.

What do exception codes 1 and 4 mean?

They are standard Modbus exception codes. Code 1 is "Illegal Function" — the function code isn't supported. Code 4 is "Slave Device Failure" — the command was valid but the device couldn't execute it. Both are signalled with the top bit set in the function code (fc + 0x80), so the client recognizes the response as an error immediately.

Does this work with FC16 for multiple registers?

The framework shown only handles FC6 (single register), because that's enough for charge mode and power limit. FC16 (Write Multiple Registers) can be added on the same pattern: unpack the PDU differently, build the frame with the matching byte count, forward, echo. Until then an FC16 request lands in the else branch and gets a clean "Illegal Function" back instead of blocking the client.

Related articles