Der Fehler, der mich schließlich dazu gebracht hat, einen eigenen Modbus-Server zu schreiben, war nicht dramatisch. Es war ein Loch. Jeden Abend, ungefähr dann, wenn der Boiler-Heizstab ansprang, lief mein Energie-Dashboard in Home Assistant ein paar Minuten flach und schnappte dann zurück. Kein Absturz — nur eine Lücke. Die Sorte, die man nicht mehr wahrnimmt, bis man auf einen Graphen starrt und sich fragt, wo 0,4 kWh geblieben sind.
Eine Verbindung, und alle wollen sie
Ich betreibe einen Huawei SUN2000-8KTL-M1 mit LUNA2000-Batterie. Der Wechselrichter spricht über einen kleinen SDongle mit der Außenwelt, und der SDongle redet Modbus TCP auf Port 502. Der Haken — und in den läuft so mancher Huawei-Besitzer direkt hinein — ist, dass der SDongle genau eine Modbus-TCP-Verbindung gleichzeitig akzeptiert.
Und ich hatte vier Dinge, die sie wollten: die Huawei-Solar-Integration von Home Assistant fürs Dashboard, den AC·THOR 9s, der den PV-Überschuss in den Warmwasserboiler kippt und dafür einen Live-Zählerwert zum Modulieren braucht, evcc für die Wallbox und die FusionSolar-Cloud. FusionSolar ist der Glückliche — er nimmt seinen eigenen Kanal hoch zu Huaweis Servern und fasst Modbus nie an. Die anderen drei haben sich gegenseitig vom einzigen Slot gestoßen. Wer zuletzt verbunden hat, gewann; der Rest bekam Connection-Resets, und das Dashboard bekam diese flache Linie.
Erster Versuch: ein transparenter Proxy
Die naheliegende Antwort ist ein Proxy: ein Prozess hält die einzige Verbindung zum SDongle, jeder Client spricht stattdessen mit dem Proxy. Ich habe mit dem ha-modbusproxy-Add-on angefangen — auf den SDongle zeigen lassen, auf 5502 lauschen, Home Assistant und den AC·THOR dorthin umbiegen.
Es funktionierte. Eine Weile. Aber ein transparenter Proxy reicht jeden Client-Read trotzdem direkt an den Wechselrichter durch, und das brachte ein subtileres Problem zum Vorschein. Modbus TCP versieht jede Anfrage mit einer Transaction-ID, und mehrere Clients, die sich eine Upstream-Verbindung teilen, koordinieren diese IDs nicht. Unter Last kann ein Client eine Antwort bekommen, die für die Anfrage eines anderen gedacht war, sie dekodieren und in aller Ruhe glauben, die Batterie stehe bei 7%, während sie real bei 70% liegt. Selten — aber falsch auf die schlimmstmögliche Art, weil es lautlos passiert.
Die Lösung, die blieb: gar nicht mehr mit dem Wechselrichter reden
Also habe ich die Clients gar nicht mehr mit dem Wechselrichter reden lassen. Statt Reads weiterzuleiten, frage ich den SDongle selbst ab, einmal, nach Plan, cache jedes Register, das mich interessiert, und bediene alle Clients aus diesem Cache. Es sind etwa 300 Zeilen asyncio-Python, läuft als systemd-Service auf Port 5502, und der Wechselrichter sieht immer nur einen höflichen Leser.
Die Leser-Seite ist eine Liste von Register-Batches und eine Schleife:
Jeder Batch ist ein simpler Function-Code-3-Read. Ich lasse 50 ms zwischen ihnen, um ein Gerät nicht zu hetzen, das langsamer ist als ein normaler Modbus-Zähler, und der ganze Durchlauf wiederholt sich alle zehn Sekunden. Das ist die einzige Unterhaltung, die der SDongle je führt.
Die Clients aus dem Cache bedienen
Die Server-Seite spricht gerade genug Modbus, um nützlich zu sein. Ein Read fasst den Wechselrichter nie an — er wird direkt aus dem Dict beantwortet, und entscheidend ist: Ich baue die Antwort gegen den eigenen Header des anfragenden Clients, sodass das Transaction-ID-Problem schlicht nicht auftreten kann:
Writes sind die Ausnahme. Ein Write (Function Code 6) ist meist Batteriesteuerung — dem Wechselrichter sagen, er soll laden oder entladen — und das muss echte Hardware erreichen, also leite ich diese direkt an den SDongle weiter und gebe das Ergebnis zurück. Reads sind gecacht, Writes sind echt. Diese eine Trennung ist das ganze Design.
Was es mir tatsächlich gebracht hat
Was mir unerwartet am meisten gefällt, ist die Entkopplung. Die Poll-Rate eines Clients ist jetzt völlig unabhängig von der des Wechselrichters. Home Assistant darf alle fünf Sekunden fragen, der AC·THOR jede Sekunde, evcc wann immer ihm danach ist — und der SDongle sieht trotzdem genau einen Leser, einmal alle zehn. Die flache Linie ist weg, und der AC·THOR hat seither nie wieder seinen Zählerwert verloren.
Außerdem hat es mir einen sauberen Ort gegeben, um die Zahlen aufzuhängen, die mich wirklich interessieren. Auf diesen gecachten Registern habe ich eine Handvoll Template-Sensoren gebaut: Eigenverbrauch bei rund 65,9%, Autarkiegrad 76,9%, die Batterie mit 97,2% Round-Trip-Wirkungsgrad. Genau die Werte, die einem die FusionSolar-App nie ganz an einem Ort zeigt — und das Thema des nächsten Teils.
Der ehrliche Haken
Ein Cache kann veralten, und ein Energie-Dashboard, das selbstbewusst lügt, ist schlimmer als eines mit einer ehrlichen Lücke. Fällt der SDongle aus — ein Firmware-Reboot, ein zickiger Switch — läuft die Leser-Schleife weiter ins Leere und die gecachten Werte altern still vor sich hin. Also logge ich es: Ist der Cache älter als 120 Sekunden, feuert eine Warnung, und der Retry bremst ab, statt ein Gerät zu bombardieren, das nicht antwortet. Clients bekommen weiter den letzten bekannten Wert, was für einen Leistungsgraphen der richtige Fehlerfall ist — eine gehaltene Linie schlägt ein Loch — aber man will wissen, wann es passiert.
Wer Home Assistant wie ich auf einer VM betreibt — ich habe früher darüber geschrieben, wie man es auf eine Azure-Linux-Box bekommt — für den kostet ein 300-Zeilen-Python-Service daneben fast nichts, und er ist seither der stabilste Teil meines Solar-Setups. Nächster Teil: aus diesen gecachten Registern die Autarkie- und Eigenverbrauchswerte machen, die einem tatsächlich sagen, ob die Batterie ihr Geld wert war.


