Loading...
Author Cloudapp
E.G.

Robuster Modbus-Proxy: Reconnect, Stale-Cache-Erkennung und Timeouts richtig machen

3. Juli 2026
Inhaltsverzeichnis

Ein selbstgebauter Modbus-Cache-Proxy läuft im Sommer wochenlang ohne Murren — bis zur ersten Nacht, in der der Wechselrichter abschaltet, oder bis zum ersten Firmware-Reboot des SDongle. Genau dann zeigt sich, ob du einen Proxy oder eine Zeitbombe gebaut hast. Bei mir lief der erste Wurf naiv: poll, cache, ausliefern. Funktionierte tagsüber perfekt. Nachts, als der SUN2000 sich schlafen legte, hing der Poll-Loop in einem Read, der nie zurückkam — und der Proxy lieferte stundenlang stumm die letzten Tageswerte aus, als wäre nichts.

Das ist der gefährliche Fall: nicht der Absturz (den merkst du), sondern der Proxy, der weiterläuft und veraltete oder Null-Daten ausliefert, ohne dass irgendwer etwas mitbekommt. Dieser Post ist das Reliability-Playbook, das aus meinem Proxy ein Gerät gemacht hat, dem ich vertraue: Reconnect mit Backoff, Stale-Cache-Erkennung, saubere Timeouts. Den Proxy selbst baue ich im Modbus-Caching-Grundlagen-Post auf — hier geht es ausschließlich um die Robustheit darunter.

Der Fehlerfall, den kein Anfänger-Guide abdeckt

Tutorials zeigen den Happy Path: SDongle verbinden, Register lesen, fertig. Was sie verschweigen: Der SDongle ist eine langsame, eigenwillige Hardware. Er droppt die Verbindung nachts, er braucht nach einem Firmware-Reboot eine Minute, bis er wieder antwortet, und er verträgt keine zu schnellen aufeinanderfolgenden Reads. Ein naiver asyncio-Read ohne Timeout blockiert dann unendlich, und dein Cache friert auf dem letzten Wert ein. HA zeigt brav weiter Zahlen an — nur stimmen sie nicht mehr.

Die Lösung hat drei Säulen: Erstens jeder einzelne Read bekommt ein hartes Timeout und das Spacing, das der SDongle braucht. Zweitens der Poll-Loop versucht es schnell erneut und geht dann in Backoff, statt zu hängen. Drittens — und das ist der Teil, den fast niemand baut — der Proxy macht seine eigene Veraltung sichtbar, damit Home Assistant darauf alarmieren kann.

Schritt 1 — Per-Batch-Timeout und das 50-ms-Spacing

Der Wechselrichter wird in Register-Batches gepollt. Jeder Batch bekommt sein eigenes Timeout (im read_batch-Helper als asyncio.wait_for), und zwischen zwei Reads liegen 50 Millisekunden Pause — der SDongle ist zu langsam, um Reads Schlag auf Schlag zu beantworten, und straft Hektik mit Timeouts ab. Entscheidend ist die Unterscheidung der beiden Exceptions: Ein einzelner TimeoutError ist verkraftbar (ein Batch fehlt halt diesmal), aber jede andere Exception bedeutet, dass die Verbindung vermutlich tot ist — dann verlassen wir den Loop sofort per break, statt weiter gegen eine tote Socket zu rennen.

for start, count in REGISTER_BATCHES:
    try:
        values = await read_batch(reader, writer, start, count)
        if values:
            async with cache_lock:
                for i, val in enumerate(values):
                    register_cache[start + i] = val
        await asyncio.sleep(0.05)  # 50ms zwischen Readsder SDongle ist langsam
    except asyncio.TimeoutError:
        fail_count += 1
    except Exception:
        fail_count += 1
        break  # Verbindung vermutlich kaputt, Loop verlassen

Der break ist der Kern: Ein einzelner Timeout darf den Batch-Durchlauf nicht abbrechen — sonst verlierst du bei jeder vorbeiziehenden Störung den halben Registersatz. Aber eine echte ConnectionError oder ein abgerissener Stream muss den Durchlauf beenden, damit der äußere Loop einen frischen Reconnect aufbauen kann, statt blind weiterzulesen.

Schritt 2 — Fast-Retry-then-Backoff und die Stale-Cache-Warnung

Der äußere Reader-Loop entscheidet, wie oft neu verbunden wird. Die Logik ist bewusst asymmetrisch: Nach einem erfolgreichen Poll warten wir das normale POLL_INTERVAL (10 s). Schlägt ein Poll fehl, versuchen wir es schnell wieder — gedeckelt auf 10 s, damit ein kurzer Schluckauf in Sekunden überbrückt wird, ohne den SDongle mit Reconnect-Versuchen zu überfluten. Und dann der wichtigste Teil: Wenn der Cache älter als 120 Sekunden ist, schreiben wir eine explizite Cache stale-Warnung ins Log.

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)

Diese eine Logzeile ist der Unterschied zwischen einem Proxy, der lügt, und einem, der ehrlich ist. Sie macht die Veraltung beobachtbar. In Home Assistant lässt sich das auf der Konsumentenseite abfangen: Ein Sensor, der über Minuten nicht mehr aktualisiert wird, kippt auf unavailable — und darauf kannst du eine Push-Benachrichtigung legen, genau wie auf jede andere Anomalie (siehe das Muster im PV-String-Anomalie-Post).

Schritt 3 — Client-Idle-Timeout gegen tote Verbindungen

Die zweite Seite des Proxys sind die Clients (HA, evcc, ein zweites Dashboard). Ohne Idle-Timeout sammeln sich tote Client-Verbindungen an — ein HA-Neustart, ein abgestürzter Container, und die alte Socket bleibt offen hängen. Jeder Modbus-Request beginnt mit einem 7-Byte-MBAP-Header; wir lesen ihn mit einem 60-Sekunden-Timeout. Kommt nichts oder weniger als 7 Bytes, ist der Client weg und wir schließen die Verbindung sauber.

header = await asyncio.wait_for(client_reader.read(7), timeout=60)
if len(header) < 7:
    break  # Client disconnected

60 Sekunden sind großzügig — HA pollt typischerweise alle 30–60 s, also überlebt eine gesunde Verbindung den Timeout locker. Eine tote Verbindung dagegen schickt nie wieder einen Header und wird nach spätestens einer Minute aufgeräumt, statt einen Slot und Speicher zu belegen.

Konfiguration — nie echte LAN-IPs

Alle veränderlichen Werte stehen oben in einem Config-Block. Trag deine eigene SDongle-Adresse ein — eine DHCP-reservierte LAN-Adresse ist ideal, damit sie sich nicht ändert. Der Port ist je nach Firmware 502 oder 6607. Veröffentliche niemals deine echte LAN-IP in einem Gist oder Forum-Post; nutz Platzhalter, so wie hier.

# Configuration (mit eigenen Werten ersetzen)
SDONGLE_HOST = "YOUR_SDONGLE_IP"   # z.B. eine DHCP-reservierte Adresse im LAN
SDONGLE_PORT = 502                 # oder 6607, je nach Firmware
DEVICE_ID = 1

SERVER_HOST = "0.0.0.0"
SERVER_PORT = 5502
POLL_INTERVAL = 10  # Sekunden

Zahlen aus dem Langzeitbetrieb

Die Werte hier sind nicht aus der Luft gegriffen, sondern aus Monaten realem Betrieb mit einem Huawei SUN2000 SDongle nachjustiert. 50 ms Spacing: darunter häuften sich Timeouts, darüber wurde der Gesamt-Poll spürbar träge. 10 s Poll-Intervall: feiner braucht es niemand für PV-Daten und schont den SDongle. 120 s Stale-Schwelle: das ist zwei verpasste Polls plus Reserve — genug, um einen einzelnen Aussetzer nicht zu eskalieren, aber kurz genug, um einen echten Ausfall zeitnah zu melden. 60 s Client-Idle: deckt jedes vernünftige HA-Scan-Intervall ab. Pass sie an deine Hardware an, aber fang mit diesen an.

Häufige Fragen

Warum nicht einfach das fertige ha-modbusproxy-Add-on nutzen?

Kannst du — das Add-on ist gut und nimmt dir die Arbeit ab. Dieser Post ist für die, die ihren eigenen Proxy gebaut haben (oder verstehen wollen, was darunter passiert) und die Reliability-Schicht selbst kontrollieren müssen. Die Failure-Modes und Schwellenwerte hier gelten konzeptionell für jeden Caching-Proxy, egal ob selbstgebaut oder Add-on.

Mein Cache friert manchmal trotzdem ein — woran liegt das?

Fast immer am fehlenden break im Batch-Loop: Wenn die Verbindung stirbt, ein Read aber nur einen TimeoutError statt einer ConnectionError wirft, läuft der Loop weiter gegen die tote Socket und der äußere Loop baut nie neu auf. Stell sicher, dass eine echte Verbindungsstörung den Batch-Durchlauf verlässt. Zweiter Verdächtiger: ein read ganz ohne asyncio.wait_for — ein einziger Read ohne Timeout reicht, um den gesamten Loop unendlich zu blockieren.

Wie alarmiere ich in Home Assistant auf einen veralteten Cache?

Am einfachsten über die Sensor-Veraltung selbst: Ein Modbus-Sensor, der keine frischen Werte mehr bekommt, geht nach einigen verpassten Scans auf unavailable. Darauf legst du eine Automation mit einem state-Trigger auf unavailable und einer for-Dauer von ein paar Minuten gegen kurze Aussetzer. Wer es expliziter mag, baut einen Template-Binary-Sensor, der das Alter des letzten Updates gegen eine Schwelle prüft.

Welcher SDongle-Port stimmt — 502 oder 6607?

Hängt von der SDongle-Firmware ab. Ältere Firmware spricht oft den Standard-Modbus-Port 502, neuere haben Modbus-TCP teils auf 6607 verschoben oder erfordern, dass du es in der FusionSolar-App erst aktivierst. Probier 502 zuerst; wenn der Connect sofort refused wird, nimm 6607. Tut sich gar nichts, ist Modbus-TCP am Dongle wahrscheinlich noch deaktiviert.

Verwandte Artikel