A standalone Python library that reads and controls Growatt solar inverters over Modbus, exposed as a normal, object-oriented Python API.
The register maps are based on homeassistant-solax-modbus (Apache-2.0), whose Growatt plugin they are extracted from. This library keeps that project's licence and is a derived work of it.
- It consumes the connection abstraction, not a backend: you build a
modbus_connection.ModbusUnitand hand it over. You choose and own the transport. - Five generations, five register maps. Growatt's protocol generations are not layers on one map — the same input register means one thing on an SPF and something else on a MIN TL-XH. Each generation therefore has its own device class and its own components, so an attribute always means what its name says.
- A model only reads the registers it has. Every field carries the
allowedtypesmask it has upstream; the inverter'sVariantbits (phase count, PV / AC / hybrid, MPPT count, backup output, dry contact box) select the subset at construction, viarestrict_fields. This is not cosmetic: a Modbus block read is atomic, so one register the inverter does not serve would fail every field sharing its block. - Status and settings refresh separately.
async_update_status()reads what the inverter measures,async_update_settings()what it has been told to do, andasync_update()does both — so a consumer can put the configuration registers on a slower schedule without reimplementing the poll. - Settings are writable, by the same names they are read under — a label for
a mode, a
datetime.timefor a schedule slot, a number for a limit. Ranges are validated before anything reaches the inverter, and a setting packed into part of a register is written read-modify-write, so its neighbours survive.
| Generation | Models | Class | Fields |
|---|---|---|---|
GEN |
Legacy PV: TL-S, TL3-S, NEO | Gen1Inverter |
46 |
GEN2 |
MOD / MID TL3-X PV | Gen2Inverter |
77 |
GEN3 |
SPH, SPA storage | Gen3Inverter |
192 |
GEN4 |
MIN / MOD / MID / WIT TL-XH hybrid, TL-X PV | Gen4Inverter |
322 |
SPF |
SPF, SPE off-grid | SpfInverter |
69 |
detect() identifies the generation and the model's options from the serial
number, using the same prefix tables as upstream — 63 serial prefixes and 20
firmware prefixes, covering MIN, MOD, MID, MAX, MIC, SPH, SPA, SPE, SPF, WIT and
NEO families.
Within a generation, the Variant bits decide which fields exist:
| Bits | Selects |
|---|---|
X1 / X3 |
single- or three-phase grid registers |
PV / AC / HYBRID / MIC |
inverter category |
MPPT3 … MPPT10 |
PV strings beyond the two every model has |
EPS |
the backup-output registers |
DCB |
the dry-contact-box registers |
APX |
the APX HV battery's own register bank |
EPS and DCB are opted into (detect(read_eps=True)). APX is detected: a
Gen4 hybrid reads the BMS type the inverter reports and only then polls the 29
blocks the APX bank spans, because a hybrid with an LG or ARK pack refuses every
one of them. detect(read_apx=True) forces it on for a device that misreports.
import asyncio
from modbus_connection import ModbusTcpParams
from modbus_connection.tmodbus import ModbusConnection
from growatt_modbus import Gen4Inverter, detect
async def main() -> None:
conn = ModbusConnection(ModbusTcpParams(host="192.168.1.50", framer="rtu"))
try:
inverter = await detect(conn.for_unit(1), read_eps=True)
assert isinstance(inverter, Gen4Inverter) # a MIN TL-XH, say
await inverter.async_update()
print("Serial:", inverter.serial_number)
print("Variant:", repr(inverter.variant))
print("PV1 power:", inverter.hybrid_status.pv_power_1, "W")
print("Battery SOC:", inverter.hybrid_status.battery_soc, "%")
print("Battery power:", inverter.battery_combined_power, "W")
print("Fault:", inverter.inverter_fault_text)
# Settings are written by the same names they are read under.
await inverter.hybrid_settings.write("ems_charging_rate", 80)
await inverter.hybrid_settings.write("time_1_mode", "Battery First")
finally:
await conn.close()
asyncio.run(main())If you already know the model, skip detection:
from growatt_modbus import Variant, build
inverter = build(unit, Variant.SPF | Variant.HYBRID | Variant.X1, "KAM0000001")detect() returns whichever device class matches the inverter it found, so
narrow it (with isinstance, or match) before reaching for a generation's own
sub-systems.
Passing the serial number matters for the handful of models whose registers a prefix rules out — a KAM-series SPF has one PV input, so its PV2 registers are never read.
Each sub-system is an independently updatable component with its own listeners:
await inverter.hybrid_status.async_update() # just the live measurements
unsub = inverter.hybrid_status.add_update_listener(refresh_my_entity)inverter.async_update() refreshes each of them in turn — 2 requests for an
SPF, 20 for an SPH, 25 for a MIN TL-XH and 54 with an APX battery, none wider
than the 100 registers the inverter answers.
The *_status components hold what the inverter measures; the *_settings
components hold what it has been told to do, and change only when something
writes them. Each group has its own method, so the settings can go on a slower
schedule and be read back on demand after a write:
await inverter.async_update_status() # every cycle
await inverter.async_update_settings() # rarely, and after a write
await inverter.hybrid_settings.write("ems_charging_rate", 80)
await inverter.async_update_settings() # read back what took effectasync_update() is unchanged: it does both, in one report.
What that takes off the fast schedule is most of the poll's width — 1 request of
an SPF's 2, 3 of a Gen2's 9, 7 of an SPH's 20, 11 of a MIN TL-XH's 25, and 25 of
the 53 an APX hybrid issues (205 registers of its 838). The APX battery's fifteen
serial numbers and monitoring versions ride with the settings: they change less
often still, and twelve of them are islands 40 registers apart that cost a
request each. Module 1's live readings are battery_module_1_status and stay on
the fast side.
Poll the settings at least once at startup even if the consumer only wants
readings: firmware_control_version comes from settings, and
Gen4Inverter.battery_voltage scales itself from a hybrid_settings field.
A poll reads each sub-system independently, the way the integration reads its
blocks: one slow or refused block does not take the rest of the poll with it.
Every update method returns an UpdateReport — a failed component keeps its
previous values, does not notify its listeners, and is listed by attribute name
with its error, while every other component refreshes and notifies once that
poll is done. A report names only what the method it came from polled, and a
settings poll fires its own components' listeners when that call is done, so it
does not hold up the readings; async_update() is one cycle and one
notification, firing nothing until both halves have been read. A dead link
(ModbusConnectionError) raises, and so does a timeout on the very first
component: nothing answered at all, so the inverter is asleep or unreachable and
walking the rest would only pay a timeout each. Once any component has
answered — refreshed or refused — a later timeout is contained like any other
failure:
report = await inverter.async_update()
for name, error in report.failed.items():
print(f"{name} kept its previous values: {error}")Growatt inverters speak Modbus RTU on RS-485, reached either directly over
serial or through a transparent RTU-over-TCP gateway (hence framer="rtu" on a
TCP connection above). The library never opens a connection itself — it takes
the ModbusUnit you give it.
ASCII framing over TCP is not supported. Do not construct the connection you
hand this library with framer="ascii"; the register semantics here assume RTU
or socket framing, and ASCII-over-TCP is out of scope.
script/query.py identifies the inverter, reads it once and prints everything
it has, which is the quickest way to see whether one is wired and addressed
correctly:
uv run script/query.py /dev/ttyUSB0 --transport serial --unit 1
uv run script/query.py 192.168.1.50 --unit 1 --framer rtuIt prints the detected variant and the read count as well, so what this inverter
turned out to be — and how few requests its poll takes — is visible against real
hardware rather than only in the tests. Add --eps, --dcb or --apx to bring
in the register groups those options carry.
Every entity in the upstream Growatt plugin that carries register knowledge is modelled — 616 sensor and 145 writable declarations, translated mechanically and then checked back against the source field by field on address, register space, word count, scale, signedness, option map and writability.
Two things upstream does are deliberately not carried over, because they are Home Assistant concerns rather than device knowledge: entity presentation (icons, device classes, entity categories, translation keys), and the UI-local state behind the Gen4 time-slot editor. The slot registers themselves are modelled directly — start time, end time, priority mode and the enable bit are four fields over the two registers the inverter actually has, so the upstream "update" and "clear" buttons are not needed. The upstream buttons that do write a register (clock sync, and the "not limited" power-limit command) are modelled as writable fields instead.
Where upstream declares one key twice within a single generation, at two different registers, both are kept here under distinct names — the collision is real, and dropping either would lose a register:
inverter_temperature(input 93) andinverter_temperature_alt(input 32) on Gen2;battery_type(holding 1048, the setting) andbattery_type_reported(input 119, the reported value, with a different code map) on Gen3.
uv sync
uv run pytest
uv run ruff check . && uv run ruff format --check . && uv run mypyTests run against the in-memory mock backend that ships with modbus-connection
(its mock_modbus_unit pytest fixture) — no hardware, no Modbus server, and no
Home Assistant.