Loading...
Author Cloudapp
E.G.

Modbus-TCP-Cache-Proxy in Python selbst bauen: ein Wechselrichter, beliebig viele Home-Assistant-Clients

19. Juni 2026
Inhaltsverzeichnis

Der Huawei SUN2000 SDongle hat eine unangenehme Eigenschaft, über die man erst stolpert, wenn man mehr als ein Gerät anbinden will: Er akzeptiert genau eine gleichzeitige Modbus-TCP-Verbindung. Sobald Home Assistant pollt, bekommt der AC·THOR keine Antwort mehr; hängt sich evcc dazwischen, fliegt einer von beiden raus. In meinem Setup wollten drei Clients gleichzeitig dieselben Register lesen — und der Dongle ließ genau einen durch.

Der übliche Rat lautet: nimm das ha-modbusproxy-Add-on. Funktioniert. Aber ich wollte verstehen, was darunter passiert, und ich wollte keinen Black-Box-Container für etwas, das im Kern erstaunlich überschaubar ist. Also habe ich den Proxy selbst geschrieben: rund 300 Zeilen asyncio-Python, die den SDongle einmal alle 10 Sekunden in einen In-Memory-Register-Cache pollen und FC3-Reads an beliebig viele parallele Clients ausliefern. Dieser Post ist die Entwickler-Vertiefung zu meinem Konzeptpost über Caching-Modbus-Proxys — dort geht es um das Warum, hier um das Wie auf Byte-Ebene.

Das Problem: ein Upstream, viele Clients

Modbus-TCP ist ein simples Request-Response-Protokoll, aber der SDongle ist als Slave mit genau einem Master gedacht. Mehrere Master gleichzeitig sind im Standard nicht vorgesehen, und Huawei setzt das hart durch. Die Lösung ist ein Proxy, der sich gegenüber dem Dongle wie der eine erlaubte Master verhält und gegenüber allen anderen Geräten selbst wie ein Modbus-Slave auftritt. Entscheidend ist die zweite Hälfte: Er beantwortet Client-Reads nicht durch Weiterreichen an den Dongle, sondern aus einem Cache. So sieht der Dongle nur einen ruhigen, periodischen Poller, egal wie viele Clients hinten dranhängen.

Die Konfiguration: das Einzige, was du anpasst

Den ganzen Proxy parametrisiere ich über eine Handvoll Konstanten am Dateianfang. Im Normalfall änderst du nur die IP deines SDongle und vielleicht den Listen-Port. Alles andere passt für eine Standard-Huawei-Installation.

# === Configuration ===
SDONGLE_HOST = "192.0.2.10"   # <- deine Huawei SDongle IP (RFC 5737 Beispiel)
SDONGLE_PORT = 502
DEVICE_ID = 1

SERVER_HOST = "0.0.0.0"        # auf allen Interfaces lauschen
SERVER_PORT = 5502
POLL_INTERVAL = 10            # Sekunden

Der Proxy lauscht auf Port 5502 statt 502, damit er parallel zum eigentlichen Dongle laufen kann und keine root-Rechte für einen privilegierten Port braucht. In Home Assistant, AC·THOR und evcc trägst du dann einfach die IP des Proxy-Hosts und Port 5502 ein — keiner der Clients merkt, dass er nicht direkt mit dem Dongle redet.

Register-Batching: weniger Roundtrips zum Dongle

Der SDongle ist langsam, und jeder einzelne Read kostet einen kompletten TCP-Roundtrip. Statt jedes Register einzeln abzufragen, lese ich zusammenhängende Blöcke in einem Rutsch. Modbus erlaubt bis zu 125 Register pro FC3-Read; ich gruppiere die Register, die ich brauche, in wenige Batches entlang der natürlichen Lücken im Huawei-Mapping.

REGISTER_BATCHES = [
    (32016, 4),   # PV string 1/2 voltage + current
    (32064, 2),   # Input power (int32)
    (32080, 2),   # Active power (int32)
    (32106, 2),   # Cumulative energy yield (uint32)
    (32114, 2),   # Daily energy yield (uint32)
    (37760, 1),   # Battery SOC
    (37765, 2),   # Battery power (int32)
    (37780, 8),   # Battery total charge/discharge + daily
]

Jeder Eintrag ist ein (start_address, count)-Tupel. Diese acht Batches decken bei mir alles ab, was die Clients brauchen — PV-Strang-Werte, Leistung, Ertrag und der komplette Batterie-Block. Pro Poll-Zyklus sind das acht kleine Reads statt Dutzender Einzelabfragen, und der gesamte Zyklus ist in deutlich unter einer Sekunde durch.

Ein FC3-Batch lesen: MBAP-Frame packen und entpacken

Hier wird es interessant. Ein Modbus-TCP-Frame besteht aus dem 7-Byte MBAP-Header (Transaction ID, Protocol ID, Length, Unit ID) plus der PDU (Function Code + Nutzdaten). Mit struct.pack baue ich den Request-Frame, schreibe ihn an den Dongle und entpacke die Antwort wieder. Das Format-String ">HHHBBHH" kodiert exakt diese Struktur: drei big-endian uint16 für den MBAP-Kopf, dann Unit, Function Code und die beiden uint16 für Startadresse und Anzahl.

async def read_batch(reader, writer, start, count):
    req = struct.pack(">HHHBBHH", 0, 0, 6, DEVICE_ID, 3, start, count)
    writer.write(req)
    await writer.drain()
    resp = await asyncio.wait_for(reader.read(9 + count * 2), timeout=3)
    resp_tx, resp_proto, resp_len, resp_unit, resp_fc, byte_count = struct.unpack(
        ">HHHBBB", resp[:9])
    if resp_fc >= 0x80:  # Exception
        return None
    data = resp[9:]
    if len(data) >= count * 2:
        return struct.unpack(">" + "H" * count, data[:count * 2])

Zwei Details sind wichtig. Das asyncio.wait_for(..., timeout=3) verhindert, dass ein hängender Dongle den ganzen Poller blockiert — bleibt die Antwort aus, läuft der Read in einen Timeout und der Zyklus geht in den Backoff. Und die Prüfung resp_fc >= 0x80 fängt Modbus-Exceptions ab: Setzt der Dongle das oberste Bit im Function Code, ist es keine Daten-Antwort, sondern ein Fehler — dann gebe ich None zurück statt Müll zu entpacken. Die int32/uint32-Werte aus den Batches setzt der Caller später aus je zwei aufeinanderfolgenden uint16-Registern zusammen.

Den Cache an jeden Client ausliefern

Die zweite Hälfte des Proxys ist die Server-Seite. Verbindet sich ein Client und schickt einen FC3-Read, beantworte ich ihn nicht vom Dongle, sondern aus dem In-Memory-Cache. Ich lese die angefragte Startadresse und Anzahl aus der Client-PDU, ziehe die Werte unter einem asyncio.Lock aus dem Cache-Dictionary und baue eine valide Modbus-Antwort mit korrektem MBAP-Header zurück.

if fc == 3 and len(pdu) >= 5:
    reg_addr, reg_count = struct.unpack(">HH", pdu[1:5])
    values = []
    async with cache_lock:
        for i in range(reg_count):
            values.append(register_cache.get(reg_addr + i, 0))
    byte_count = reg_count * 2
    resp_pdu = struct.pack(">BB", fc, byte_count)
    resp_pdu += struct.pack(">" + "H" * reg_count, *values)
    resp_header = struct.pack(">HHHB", tx_id, 0, len(resp_pdu) + 1, unit_id)
    client_writer.write(resp_header + resp_pdu)

Der cache_lock ist nicht optional: Der Reader-Loop schreibt den Cache, während mehrere Client-Handler ihn gleichzeitig lesen — ohne Lock könntest du eine halb aktualisierte Register-Reihe ausliefern. Wichtig ist außerdem, dass ich die tx_id (Transaction ID) des Clients in der Antwort spiegele; ein korrekter Modbus-Master matcht Antworten genau über dieses Feld, und ein falscher Wert lässt manche Clients die Antwort verwerfen. Fehlende Register beantworte ich mit 0 statt mit einem Fehler — das hält wählerische Clients zufrieden.

Der Reader-Loop: pollen, Backoff, Stale-Warnung

Zusammengehalten wird das Ganze von einem einzigen langlaufenden Task, der den Dongle in der Schleife pollt. Solange alles gut geht, läuft er im normalen 10-Sekunden-Takt. Schlägt ein Poll fehl, geht er in einen kürzeren Retry, und wenn der Cache zu alt wird, schreibt er eine Warnung ins Log.

async def reader_loop():
    retry_delay = 5
    while True:
        success = await read_sdongle()
        if success:
            last_update = time.time()
            retry_delay = POLL_INTERVAL
        else:
            retry_delay = min(retry_delay, 10)
            age = time.time() - last_update if last_update > 0 else -1
            if age > 120:
                logger.warning(f"Cache stale for {age:.0f}s")
        await asyncio.sleep(retry_delay)

Die Stale-Warnung ab 120 Sekunden ist mein Frühwarnsystem: Steht sie im Log, weiß ich, dass der Dongle nicht mehr antwortet, bevor die Clients überhaupt komische Werte zeigen. Bewusst gehalten habe ich, dass der Cache bei Ausfall die letzten gültigen Werte behält statt auf null zu fallen — sonst würde jeder Dongle-Hänger eine PV-Leistung von 0 W an alle Clients durchreichen und in Home Assistant Fehlalarme und kaputte Statistiken auslösen.

In Home Assistant einbinden und kontrollieren

Auf der HA-Seite ändert sich gegenüber einer Direktanbindung fast nichts — du zeigst den Modbus-Hub einfach auf den Proxy statt auf den Dongle. Bei mir laufen jetzt drei Clients gegen denselben Upstream, und im PV-Dashboard sehe ich sensor.pv_power, sensor.battery_soc und sensor.pv_total_yield live aktualisiert — alle gleichzeitig durch den Proxy bezogen, ohne dass sich die Clients gegenseitig die Verbindung wegnehmen. Wie ich aus diesen Rohwerten Eigenverbrauch und Autarkie berechne, steht im Post über Eigenverbrauchs- und Autarkie-Sensoren.

Häufige Fragen

Warum nicht einfach das ha-modbusproxy-Add-on nehmen?

Kannst du machen, und für die meisten ist das die richtige Wahl. Ich wollte aber verstehen, was unter der Haube passiert, und volle Kontrolle über Batching, Cache-Verhalten und Logging haben. Wenn ein Add-on für dich eine Black Box bleiben soll, nimm das Add-on; wenn du den Mechanismus verstehen oder erweitern willst (z. B. eigene berechnete Register, andere Poll-Intervalle pro Batch), ist der selbstgebaute Proxy genau richtig.

Liefert der Cache nicht veraltete Werte aus?

Maximal so alt wie dein Poll-Intervall, hier 10 Sekunden. Für PV-Leistung, Batterie-SOC und Ertrag ist das völlig ausreichend — diese Werte ändern sich nicht im Sekundentakt sinnvoll. Wer schneller braucht, senkt POLL_INTERVAL, sollte aber im Blick behalten, dass der SDongle bei zu aggressivem Polling selbst zickt. 10 Sekunden ist für mich der stabile Sweet Spot.

Unterstützt der Proxy auch Schreibzugriffe (FC6/FC16)?

Diese Version nicht — sie ist bewusst read-only und behandelt nur FC3. Das deckt die Sensor-Anbindung ab, um die es hier geht. Schreibzugriffe (etwa eine Batterie-Lademodus-Steuerung) müsstest du ergänzen, und dann gilt das 1-Connection-Limit erst recht: Writes dürfen sich nicht überlappen. Für reine Datenanbindung mehrerer Clients ist read-only der sichere und ausreichende Weg.

Was passiert, wenn der SDongle kurz wegbricht?

Der Reader-Loop geht in den Retry-Backoff und der Cache behält die letzten gültigen Werte, statt auf null zu fallen — die Clients sehen also keinen Einbruch, nur eingefrorene Werte. Ab 120 Sekunden ohne Update schreibt der Proxy eine Stale-Warnung ins Log. Kommt der Dongle zurück, füllt der nächste erfolgreiche Poll den Cache wieder auf und der normale 10-Sekunden-Takt setzt automatisch ein.

Verwandte Artikel