Loading...
Author Cloudapp
E.G.

Modbus-Schreibzugriffe durch den Cache-Proxy: Batterie & Wechselrichter in Home Assistant trotz geteilter Verbindung steuern

30. Juni 2026
Inhaltsverzeichnis

Mein Huawei-SDongle erlaubt genau eine Modbus-Verbindung gleichzeitig. Deshalb sitzt bei mir ein kleiner Caching-Proxy davor, der die Verbindung exklusiv hält und alle Lese-Anfragen aus dem Cache beantwortet — das habe ich im Caching-Proxy-Post ausführlich beschrieben. Das funktioniert hervorragend, solange man nur liest. Dann kam Tibber ins Spiel, und ich wollte die Batterie bei negativen Strompreisen forciert laden und das Einspeise-Limit dynamisch setzen — und plötzlich brauchte ich Schreibzugriffe.

Hier ist der nicht-offensichtliche Haken: Ein Cache löst nur Reads. Ein Write hat im Cache nichts verloren — er muss tatsächlich zum Wechselrichter, sonst ändert sich physisch gar nichts. Der Proxy braucht also einen Sonderfall: Lesen aus dem Cache, Schreiben direkt durchreichen, und das alles, ohne die Single-Connection-Invariante zu verletzen. Genau diesen Write-Through-Pfad zeige ich hier.

Warum ein Write nicht in den Cache darf

Der Caching-Proxy macht sein ganzes Glück daraus, dass Lese-Register sich nur langsam ändern — Batteriestand, Strang-Ströme, Tagesertrag. Die kann man bedenkenlos für ein paar Sekunden zwischenspeichern und an mehrere Clients gleichzeitig ausliefern. Ein Schreibbefehl ist das genaue Gegenteil: er ist ein Seiteneffekt, kein Wert. „Setze Register 47075 auf Lademodus forciert“ muss beim Gerät ankommen, sonst war der Befehl wirkungslos.

Modbus unterscheidet das sauber über den Function Code. Reads sind FC3 (Read Holding Registers) bzw. FC4 — die gehen in den Cache. Der Klassiker fürs Schreiben einzelner Register ist FC6 (Write Single Register), und genau den fängt der Proxy ab und behandelt ihn anders: durchreichen statt cachen.

FC6 erkennen und durchreichen

Im Request-Handler des Proxys prüfe ich den Function Code aus der PDU. Ist es eine 6 und die PDU lang genug, entpacke ich Registeradresse und Wert und reiche den Write direkt zum SDongle weiter. Klappt es, echoe ich die Standard-Write-Antwort (FC6 antwortet mit Adresse und Wert gespiegelt zurück). Klappt es nicht, liefere ich eine korrekte Modbus-Exception statt den Client hängen zu lassen:

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)

Zwei Details, die wichtig sind. Die Transaction-ID (tx_id) aus dem MBAP-Header reiche ich unverändert zurück — der Client matcht Antworten darüber, und ein falscher Wert führt zu Timeouts. Und die Fehlerantwort folgt der Modbus-Konvention: Function Code mit gesetztem höchstem Bit (fc + 0x80) plus ein Exception-Code; die 4 steht für „Slave Device Failure“. So weiß Home Assistant, dass der Schreibversuch fehlgeschlagen ist, statt blind auf eine Antwort zu warten.

Den Write auf einer kurzlebigen Verbindung weiterreichen

Jetzt der Kern, der die Single-Connection-Invariante wahrt. Der Proxy hält seine eine dauerhafte Verbindung für den Polling-Loop. Für einen Write öffne ich eine separate, kurzlebige Verbindung zum SDongle, schicke genau ein FC6-Frame, lese die Antwort und schließe sofort wieder. Weil der Write so kurz ist, kollidiert er praktisch nie mit dem Poll-Zyklus — und falls doch, ist das die Stelle, an der ein Mutex um den SDongle-Zugriff gehört (dazu in einem späteren Post mehr).

# 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

Das struct.pack(">HHHBBHH", ...) baut ein komplettes Modbus-TCP-Frame: MBAP-Header (Transaction-ID 0, Protocol-ID 0, Length 6), dann Unit-ID, Function Code 6, Registeradresse und -wert. Die Längen-/Timeout-Werte (10 s) sind großzügig — ein SDongle antwortet auf einen Write normalerweise in Millisekunden, aber unter Last lieber etwas Puffer. Eine gültige FC6-Antwort ist 12 Byte lang; bekomme ich weniger, gilt der Write als fehlgeschlagen und der Aufrufer oben sendet die Exception.

Unbekannte Function Codes sauber ablehnen

Es gibt mehr Function Codes als FC3, FC4 und FC6 — FC16 (Write Multiple Registers), FC1, FC2 und so weiter. Wenn ein Client einen Code schickt, den der Proxy nicht implementiert, ist die schlechteste Reaktion: gar nicht antworten. Dann blockiert der Client bis zum Timeout und der ganze Poll-Loop stockt. Die richtige Antwort ist die Modbus-Exception „Illegal Function“ (Code 1) — der Client weiß sofort Bescheid und macht weiter:

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)

Das ist dieselbe Exception-Mechanik wie beim Device-Failure, nur mit Code 1 statt 4. Ein gut erzogener Modbus-Proxy lässt keinen Request unbeantwortet — entweder kommt das echte Ergebnis oder ein definierter Fehler, aber niemals Stille.

Was sich damit in der Praxis steuern lässt

Mit dem Write-Through-Pfad steuere ich aus Home-Assistant-Automationen heraus direkt die Batterie. Die spannendste Anwendung ist der dynamische Stromtarif: fällt der Tibber-Preis unter eine Schwelle, schreibe ich den forcierten Lademodus, und steigt er wieder, schalte ich zurück auf Eigenverbrauch. Dazu beobachte ich Sensoren wie sensor.my_pv_batteriestand sowie sensor.batterie_gesamt_ladung und sensor.batterie_gesamt_entladung, um zu sehen, ob der Befehl tatsächlich gegriffen hat. Wie man Eigenverbrauch und Autarkie sauber misst, habe ich im Eigenverbrauch-Post gezeigt.

Wichtig: Schreib-Register und deren erlaubte Werte sind streng herstellerspezifisch und teils gefährlich (man kann eine Anlage in einen unsinnigen Zustand bringen). Übernimm die Konzepte hier, aber suche die exakten Register und zulässigen Werte im Modbus-Dokument deines eigenen Wechselrichters — und teste mit unkritischen Registern, bevor du Lademodi umstellst.

Häufige Fragen

Warum nicht einfach Writes auch cachen?

Weil ein Write kein Wert ist, sondern ein Befehl mit physischem Seiteneffekt. „In den Cache schreiben“ würde bedeuten, dass der Befehl nie das Gerät erreicht — die Batterie lädt nicht, das Power-Limit ändert sich nicht. Reads darf man cachen, weil sie idempotent sind; Writes müssen immer durchgereicht werden.

Wozu eine separate Verbindung für den Write — habe ich nicht schon eine offene?

Die dauerhafte Verbindung ist vom Polling-Loop belegt. Eine kurzlebige Extra-Verbindung nur für den Write hält die Logik einfach und das Write-Frame außerhalb des Lese-Cache-Pfads. Weil der Write in Millisekunden durch ist, bleibt die „eine Verbindung gleichzeitig“-Regel des SDongle praktisch gewahrt. Bei echtem Parallelzugriff gehört an diese Stelle ein Mutex.

Was bedeuten die Exception-Codes 1 und 4?

Es sind Standard-Modbus-Exception-Codes. Code 1 ist „Illegal Function“ — der Function Code wird nicht unterstützt. Code 4 ist „Slave Device Failure“ — der Befehl war zwar gültig, das Gerät konnte ihn aber nicht ausführen. Beide werden mit gesetztem höchstem Bit im Function Code signalisiert (fc + 0x80), sodass der Client die Antwort sofort als Fehler erkennt.

Funktioniert das auch mit FC16 für mehrere Register?

Das gezeigte Gerüst behandelt nur FC6 (Single Register), weil das für Lademodus und Power-Limit reicht. FC16 (Write Multiple Registers) lässt sich nach demselben Muster ergänzen: PDU anders entpacken, das Frame mit der passenden Byte-Anzahl bauen, durchreichen, echoen. Bis dahin landet ein FC16-Request im Else-Zweig und bekommt sauber „Illegal Function“ zurück, statt den Client zu blockieren.

Verwandte Artikel