Tutorial: SunSpec-Register eines PV-Wechselrichters über Modbus TCP auslesen

Inhalt
Eine Registernummer aus einem Forenbeitrag funktioniert. Sie funktioniert bis zur nächsten Firmware, bis zum nächsten Modell desselben Wechselrichters, bis ein zweites Gerät hinzukommt, das denselben Wert woanders ablegt. Dann stimmt die Nummer nicht mehr, und nichts sagt es: Modbus liefert, was an dieser Adresse steht, und eine plausible Zahl ist von einer richtigen nicht zu unterscheiden.
Aus diesem Grund gibt es SunSpec. Es ist ein Verzeichnis, das über den Registerraum gelegt ist, und es zu lesen kostet vier Schritte, die auf jedem Wechselrichter mit dieser Unterstützung gleich funktionieren.

Warum eine Registernummer allein nicht genügt
Modbus kennt keine Namen, keine Einheiten und keine Typen. Ein Lesevorgang liefert sechzehn Bit, und jede Deutung dieser Bits geschieht beim Lesenden. Ein Register mit dem Inhalt 1234 kann 1234 Watt bedeuten, 123,4 Volt oder 12,34 Ampere – und das Protokoll sagt dazu nichts.
SunSpec ergänzt drei Dinge, und alle drei stehen im Registerraum selbst: eine Marke, die sagt, dass das Verzeichnis vorhanden ist, eine Kette von Blöcken, die sagt, welche Messwerte es gibt und wo, und je Gruppe von Werten ein Skalierungsfaktor, der sagt, wo das Komma steht.
Die praktische Folge ist, dass ein einmal geschriebener Leser auch beim nächsten Gerät funktioniert. Das wiegt mehr, als es klingt, denn die Alternative – eine Adresstabelle je Hersteller und Firmwarestand – ist genau das, was stillschweigend veraltet.
Die Basisadresse und die Marke finden
Das Verzeichnis beginnt an einer von drei Adressen, und die ersten beiden Register sagen, ob es dort beginnt. Sie enthalten die vier Zeichen SunS, als 32-Bit-Wert also 0x53756E53.
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient("192.168.1.50", port=502)
c.connect()
for basis in (40000, 0, 50000):
r = c.read_holding_registers(basis, count=2, slave=1)
if not r.isError() and r.registers == [0x5375, 0x6E53]:
print("SunSpec ab", basis)
break
In diesem kurzen Stück Code stecken zwei Stolpersteine. Der erste ist die Verschiebung um eins, über die jeder einmal fällt: Die Dokumentation zählt Register ab eins, das Protokoll adressiert sie ab null. Eine im Handbuch als 40001 gedruckte Adresse ist auf der Leitung der Offset 40000, und ein Lesevorgang bei 40001 liefert die zweite Hälfte der Marke und keine Übereinstimmung.
Der zweite ist die Geräteadresse. Ein Wechselrichter mit eingebautem Zähler antwortet als zwei Geräte auf derselben Verbindung, jedes mit eigener Kennung und eigenem SunSpec-Verzeichnis. Die Leistung des Zählers unter der Kennung des Wechselrichters zu lesen liefert einen Wert, der kein Fehler und nicht die Wahrheit ist. Welche Kennungen in Gebrauch sind, zeigt die Modbus-Seite der Weboberfläche des Geräts – und dort wird Modbus TCP überhaupt erst eingeschaltet, denn bei den meisten Wechselrichtern ist es ab Werk aus.
Die Modellkette ablaufen
Nach der Marke folgt eine Reihe von Blöcken, und jeder Block beginnt mit zwei Registern: seiner Modellnummer und seiner Länge. Die Länge zur laufenden Position addiert ergibt den Anfang des nächsten Blocks, und eine Modellnummer von 65535 beendet die Kette.
pos = basis + 2
while True:
kopf = c.read_holding_registers(pos, count=2, slave=1).registers
modell, laenge = kopf[0], kopf[1]
if modell == 0xFFFF:
break
print(f"Modell {modell:5d} Laenge {laenge:3d} Daten ab {pos + 2}")
pos += 2 + laenge
Ein üblicher Wechselrichter antwortet mit drei oder vier Modellen, und die Nummern sagen, was jedes davon ist.
| Modell | Inhalt |
|---|---|
| 1 | Gemeinsames: Hersteller, Modellbezeichnung, Seriennummer, Firmwarestand |
| 101, 102, 103 | Wechselrichter ein-, zwei-, dreiphasig – ganze Zahlen mit Skalierungsfaktoren |
| 111, 112, 113 | Dieselben Messwerte als Gleitkommazahlen, ohne Skalierungsfaktoren |
| 160 | Die einzelnen Strings, ein wiederholter Block je MPP-Regler |
| 201 bis 204 | Zähler, ein Modell je Anschlussart |
Ob ein Gerät 103 oder 113 anbietet, ist meist eine Einstellung und keine Eigenschaft. Viele Wechselrichter haben einen Schalter zwischen ganzen Zahlen mit Skalierungsfaktoren und Gleitkomma, und er bestimmt, welche der beiden Modellfamilien in der Kette erscheint. Ein Leser, der das eine erwartet und das andere vorfindet, meldet überhaupt keine Messwerte – ein verwirrendes Anzeichen für eine Einstellung, an deren Änderung sich niemand erinnert.
Skalierungsfaktoren, Datentypen und die Ersatzwerte
Innerhalb eines Modellblocks steht die Position eines Werts in der Spezifikation und wird ab dem Anfang der Daten gezählt. Der Strom der Phase A sitzt auf Offset 2, der dazugehörige Skalierungsfaktor auf Offset 5.
Modell 103, Daten ab 40071
Offset 1 A uint16 Gesamtstrom
Offset 2 AphA uint16 Phase A
Offset 3 AphB uint16 Phase B
Offset 4 AphC uint16 Phase C
Offset 5 A_SF int16 Skalierungsfaktor fuer alle vier
40072 = 1234 Rohwert
40075 = 0xFFFE Zweierkomplement, also −2
1234 × 10⁻² = 12,34 A
Der Skalierungsfaktor ist ein vorzeichenbehafteter Zehnerexponent und gilt für eine ganze Gruppe von Werten, nicht für einen. Deshalb erscheint er einmal für vier Ströme, und deshalb liefert ein ohne ihn gelesener Strom eine hundertfach zu grosse Zahl – die in einer Hausanlage immer noch nach einem plausiblen Messwert aussieht.
Drei weitere Eigenschaften der Kodierung zählen in der Praxis. Werte über zwei Register stehen mit dem höherwertigen Wort zuerst, was der Modbus-Konvention entspricht und was fast jede Bibliothek von allein richtig macht. Textfelder haben eine feste Länge und werden mit Nullbytes aufgefüllt statt von ihnen beendet. Und ein Wert, den das Gerät nicht führt, ist nicht null, sondern ein Ersatzwert: 0x8000 bei int16, 0xFFFF bei uint16, 0x80000000 bei int32. Ein Leser, der diese als Zahlen behandelt, meldet 65535 Volt – und eine Regel, die sie verwirft, ist drei Zeilen lang.
LEER = {"int16": 0x8000, "uint16": 0xFFFF,
"int32": 0x80000000, "uint32": 0xFFFFFFFF}
def wert(roh, typ, sf):
if roh == LEER.get(typ):
return None
return roh * (10 ** sf)
Einen Wert von Anfang bis Ende lesen
Die Teile zusammen ergeben einen Leser, der das Modell findet, statt seine Adresse anzunehmen.
def modell_finden(c, basis, gesucht, slave=1):
pos = basis + 2
while True:
m, laenge = c.read_holding_registers(pos, count=2, slave=slave).registers
if m == 0xFFFF:
return None
if m == gesucht:
return pos + 2, laenge
pos += 2 + laenge
def vorzeichen(r):
return r - 0x10000 if r > 0x7FFF else r
start, _ = modell_finden(c, 40000, 103)
block = c.read_holding_registers(start, count=6, slave=1).registers
strom = block[1] # AphA, Offset 2 ab eins gezaehlt
sf = vorzeichen(block[4]) # A_SF, Offset 5
print(f"{strom * 10 ** sf:.2f} A")
Es lohnt sich, den ganzen Block in einer Anfrage zu lesen statt jeden Wert einzeln. Ein Modbus-Lesevorgang kostet unabhängig von der Länge einige Millisekunden Umlaufzeit – sechs Register in einem Aufruf kosten also so viel wie ein Register in einem Aufruf. Wichtiger noch: Alle sechs Werte stammen dann aus demselben Augenblick. Sechs getrennte Lesevorgänge für Strom, Spannung und Leistung liefern drei Grössen, die nicht zusammenpassen, und eine darauf gebaute Plausibilitätsprüfung schlägt grundlos an.
Eine Anmerkung zur Abfragehäufigkeit: Ein Wechselrichter ist keine Datenbank. Eine Anfrage alle fünf Sekunden ist unproblematisch, einmal je Sekunde ist bei manchen Geräten die Grenze, und schneller erzeugt Zeitüberschreitungen, die wie Netzfehler aussehen. Die Messwerte selbst aktualisieren sich ohnehin selten schneller als sekündlich.
Das Ganze in Home Assistant bringen
Sind die Adressen bekannt, funktioniert dasselbe Lesen auch beschreibend. Die Modbus-Anbindung von Home Assistant kümmert sich um Datentyp und Vorzeichen; nur der Skalierungsfaktor muss als Zahl eingetragen werden, denn die Anbindung liest ihn nicht aus dem Gerät.
modbus:
- name: wechselrichter
type: tcp
host: 192.168.1.50
port: 502
sensors:
- name: "Strom Phase A"
slave: 1
address: 40072
data_type: uint16
scale: 0.01
precision: 2
unit_of_measurement: "A"
device_class: current
state_class: measurement
scan_interval: 10
- name: "Wirkleistung"
slave: 1
address: 40084
data_type: int16
scale: 1
precision: 0
unit_of_measurement: "W"
device_class: power
state_class: measurement
scan_interval: 10
Der fest eingetragene scale ist der Kompromiss dieses Weges, und es lohnt sich festzuhalten, woher er kommt. Der Skalierungsfaktor wurde einmal mit dem Skript von oben gelesen; er steht im Gerät und ändert sich praktisch nie, garantiert ist das aber nicht. Nach einer Firmware-Aktualisierung dauert ein Vergleich zwischen angezeigtem Wert und dem Display des Wechselrichters zehn Sekunden und fängt einen Faktor hundert sofort ab.
Ein letztes Feld entscheidet, ob der Wert in der Energiestatistik landet statt nur auf einer Karte. Ein Leistungswert in Watt ist device_class: power mit state_class: measurement; ein Zählerstand in Kilowattstunden ist device_class: energy mit state_class: total_increasing. Beides ist nicht austauschbar, und nur das Zweite lässt sich als Energiequelle auswählen.