Tutorial: Reading SunSpec Registers From a PV Inverter Over Modbus TCP

Contents
A register number found in a forum post works. It works until a firmware update, until the next model of the same inverter, until a second device joins that stores the same value somewhere else. Then the number is wrong, and nothing says so: Modbus returns whatever is at that address, and a plausible number is indistinguishable from a correct one.
SunSpec exists for this reason. It is a directory laid over the register space, and reading it takes four steps that work identically on every inverter that implements it.

Why a Register Number Alone Is Not Enough
Modbus has no notion of names, units or types. A read returns sixteen bits, and every interpretation of those bits happens in the reader. A register holding 1234 might be 1234 watts, 123.4 volts or 12.34 amperes, and the protocol has nothing to say about which.
SunSpec adds three things on top of that, and all three are stored in the register space itself: a marker that says the map is present, a chain of blocks that says which measurements exist and where, and per group of values a scale factor that says where the decimal point goes.
The practical consequence is that a reader written once works on the next device too. That is worth more than it sounds, because the alternative – a table of addresses per manufacturer and firmware version – is exactly the thing that quietly goes out of date.
Finding the Base Address and the Marker
The map begins at one of three addresses, and the first two registers say whether it does. They hold the four characters SunS, which as a 32-bit value is 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 at", basis)
break
Two stumbling blocks live in that short piece of code. The first is the off-by-one that catches everybody once: documentation numbers registers from one, the protocol addresses them from zero. An address printed as 40001 in a manual is offset 40000 on the wire, and reading at 40001 returns the second half of the marker and no match.
The second is the unit id. An inverter with a built-in smart meter answers as two devices on the same connection, each with its own id and its own SunSpec map. Reading the meter’s power from the inverter’s id gives a value that is not an error and not the truth. Which ids are in use is shown in the Modbus page of the device’s own web interface – and that page is also where Modbus TCP has to be switched on in the first place, because it is off by default on most inverters.
Walking the Model Chain
After the marker comes a sequence of blocks, and each block begins with two registers: its model number and its length. Adding the length to the current position gives the start of the next block, and a model number of 65535 ends the chain.
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"model {modell:5d} length {laenge:3d} data at {pos + 2}")
pos += 2 + laenge
A typical inverter answers with three or four models, and the numbers say what each of them is.
| Model | Content |
|---|---|
| 1 | Common: manufacturer, model designation, serial number, firmware version |
| 101, 102, 103 | Inverter, single phase, split phase, three phase – integers with scale factors |
| 111, 112, 113 | The same measurements as floating point numbers, without scale factors |
| 160 | The individual strings, one repeating block per MPP tracker |
| 201 to 204 | Meter, one model per wiring type |
Whether a device offers 103 or 113 is usually a setting rather than a property. Many inverters have a switch between integer with scale factors and floating point, and it decides which of the two model families appears in the chain. A reader that expects one and finds the other reports no measurements at all, which is a confusing symptom for a setting nobody remembers changing.
Scale Factors, Data Types and the Sentinels
Inside a model block, the position of a value is fixed by the specification and counted from the start of the data. The phase A current sits at offset 2, and the scale factor that belongs to it at offset 5.
Model 103, data starting at 40071
offset 1 A uint16 total current
offset 2 AphA uint16 phase A
offset 3 AphB uint16 phase B
offset 4 AphC uint16 phase C
offset 5 A_SF int16 scale factor for all four
40072 = 1234 raw value
40075 = 0xFFFE two's complement, so −2
1234 × 10⁻² = 12.34 A
The scale factor is a signed exponent to base ten and applies to a whole group of values, not to one. That is why it appears once for four currents, and why reading a current without it produces a number a hundred times too large – which on a domestic system still looks like a plausible reading.
Three more properties of the encoding matter in practice. Values spanning two registers are big-endian with the high word first, which is the Modbus convention and which almost every library gets right by default. Text fields are fixed-length and padded with null bytes rather than terminated by them. And a value that the device does not implement is not zero but a sentinel: 0x8000 for int16, 0xFFFF for uint16, 0x80000000 for int32. A reader that treats those as numbers reports 65535 volts, and a rule that discards them is three lines long.
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)
Reading One Value End to End
The pieces together give a reader that finds the model rather than assuming its address.
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 counted from one
sf = vorzeichen(block[4]) # A_SF, offset 5
print(f"{strom * 10 ** sf:.2f} A")
Worth reading the whole block in one request rather than each value on its own. A Modbus read takes a few milliseconds of round trip regardless of length, so six registers in one call cost what one register in one call costs – and, more importantly, all six values then come from the same moment. Six separate reads of a current, a voltage and a power give three quantities that do not multiply together, and a plausibility check built on them fails for no reason.
A note on polling frequency: an inverter is not a database. A request every five seconds is unproblematic, once a second is at the limit on some devices, and faster than that produces timeouts that look like network faults. The measured values themselves rarely update faster than every second anyway.
Putting It into Home Assistant
With the addresses known, the same reading works declaratively. Home Assistant’s Modbus integration handles the data type and the sign; only the scale factor has to be entered as a number, because the integration does not read it from the device.
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
The hard-coded scale is the compromise in this approach, and it is worth noting where it comes from. The scale factor was read once with the script above; it is stored in the device and practically never changes, but it is not guaranteed. After a firmware update, one comparison between the displayed value and the inverter’s own screen takes ten seconds and catches a factor of a hundred immediately.
One last field decides whether the value ends up in the energy statistics rather than only on a card. A power reading in watts is device_class: power with state_class: measurement; a meter reading in kilowatt-hours is device_class: energy with state_class: total_increasing. The two are not interchangeable, and only the second can be selected as an energy source.