Yes, this is doable locally — but there's one firmware gotcha that trips most people up with the -20 models.
Short version: the GW6000-ES-20 has a local Modbus API, but the "Wi-Fi/LAN Kit-20" firmware moved local communication from plain UDP to DTLS-encrypted UDP on port 8899. That's why the usual marcelblijleven/goodwe library often just silently times out on these newer dongles — it speaks plain UDP, and the dongle now expects a DTLS handshake first.
To answer your specific questions:
Official local API? No public one, but the local Modbus interface is well understood. It's Modbus-over-UDP (not Modbus-TCP), now wrapped in DTLS.
Registers for live PV? Your inverter is the ES family, which the goodwe Python library already decodes (family="ES") — PV strings, grid, power, energy today/total, etc. You don't need to hand-roll the register map; the library does it once you can get through the DTLS layer.
30s polling? Easily. The DTLS handshake is a one-time ~1s cost per session; reads after that are ~100–250ms. Keeping a session alive at 30s intervals is no problem.
First, confirm you actually have the DTLS firmware. Send the string WIFIKIT-214028-READ to your dongle's IP on UDP port 48899. If the reply contains dtls_port:8899, you're on the DTLS firmware and plain UDP will never work.
Two ways forward:
Python / library route: I've got an open PR that adds DTLS support to the goodwe library — marcelblijleven/goodwe#136 (Add DTLS transport for newer Wi-Fi/LAN Kit-20 firmware by botts7 · Pull Request #136 · marcelblijleven/goodwe · GitHub). It's opt-in (goodwe.connect(host, family="ES", dtls=True)), keeps the Modbus decoding unchanged, and only adds the encrypted transport. It's been validated on three inverters across two families so far. You can install the branch today while it's in review.
Home Assistant route (turnkey): If you just want it in HA, I maintain a HACS integration that does the whole thing — DTLS transport, auto-discovery, and all the sensors — botts7/ha-goodwe-dtls (https://github.com/botts7/ha-goodwe-dtls). It's been running in production for a few weeks on my own unit.
One honest caveat: I proved the DTLS transport on my own hardware, but that unit is a single-phase DT/MS inverter. The ES register decoding comes from the library's existing es.py support rather than my own bench, so if you try the PR I'd love to hear whether the ES values come through correctly — it'd be a useful third-family confirmation.
If you run the discovery probe and paste back what it returns (feel free to redact the serial), I can tell you exactly which path applies to your setup.
Thank you very much for your detailed explanation!
This is exactly the kind of information I was looking for, and it makes a lot of sense.
Some time ago I performed a port scan on my inverter, and I remember that UDP port 8899 was the only service that appeared to be open. That now seems to confirm what you explained about the newer Wi-Fi/LAN Kit-20 firmware.
I'll run the WIFIKIT-214028-READ discovery probe and post the result here.
If my inverter uses the DTLS firmware, I'd be very happy to test your DTLS branch on my GW6000-ES-20 and report back whether the ES register decoding works correctly. Hopefully I can help validate it on another ES family installation.
Thanks again for your excellent work and for sharing your knowledge!
My inverter is installed in my mountain house, while I live in Trieste.
The two locations are connected through an IPsec site-to-site VPN, so from a network perspective they are on two routed LANs.
Do you know if the DTLS communication works correctly over an IPsec VPN between two different LANs, or does it require being on the same local broadcast domain?
Interestingly, the official GoodWe app only works when my phone is connected locally via Wi-Fi to the inverter. It does not work across my VPN, even though the two networks can communicate with each other.
Have you ever tested the DTLS transport over a routed VPN connection?
I haven't tested this over a VPN myself — all my testing has been on the same LAN as the dongle — so take this as a hypothesis rather than a confirmed answer. But the fact that the official app fails over your VPN is actually a useful clue.
There are two separate mechanisms, and I think only one of them is the problem:
Discovery uses a UDP broadcast (WIFIKIT-214028-READ to port 48899). Broadcasts don't cross routers or IPsec tunnels, so anything that finds the dongle by broadcasting — like the official app — won't see it from Trieste. That would explain the "only works on local Wi-Fi" behaviour, and it's a broadcast limitation, not an encryption one.
The actual DTLS data channel is plain unicast UDP to dongle_ip:8899. Unicast should route across subnets and through an IPsec tunnel like any other traffic — so if you address the dongle by its mountain-house IP directly (skipping discovery), I'd expect it to work. But that's the part I haven't verified over a VPN.
Both my HACS integration and the library PR let you enter the dongle's IP manually instead of relying on discovery, which is the path you'd want to try here.
If you do test it, a couple of things I'd watch: MTU/fragmentation (IPsec shrinks the usable MTU, and the DTLS handshake uses larger packets — if the handshake stalls, that's my first suspect, and MSS clamping usually helps), and making sure UDP/8899 is open both directions through the tunnel.
A quick standalone test before touching Home Assistant: from Trieste, try openssl s_client -dtls -connect <mountain_dongle_ip>:8899 — if the handshake completes across the tunnel, the integration almost certainly will too. I'd genuinely be curious to hear how it goes either way.
That's actually a really helpful result — two takeaways:
Your VPN is fine. The dongle answered discovery across the IPsec tunnel, so UDP is routing correctly between the two LANs. That was the main thing I was unsure about, so good to have it confirmed — the VPN isn't your blocker.
The missing dtls_port is probably good news for you. My dongle (a Kit-20) replies with dongle@sn,dtls_port:8899,… — the dtls_port field is what signals the newer DTLS-only firmware. Yours came back as ccm@sn,ccm@sn,Solar-… with no dtls_port at all. I'm inferring here rather than certain, but that pattern looks like an older/different comms module (ccm) that likely still speaks plain, unencrypted UDP Modbus on port 8899 — i.e. the original protocol, not DTLS.
If that's right, you probably don't need my DTLS PR at all — the standard, unmodified marcelblijleven/goodwe library should just work. I'd try that first:
async def main():
inv = await goodwe.connect("<dongle_ip>", family="ES")
data = await inv.read_runtime_data()
for sensor in inv.sensors():
print(sensor.id_, data.get(sensor.id_), sensor.unit)
asyncio.run(main()
(plain UDP, no dtls=True).
If that returns your PV/grid data, you're done .
Only if that plain call times out would I circle back and try dtls=True as a fallback. But given no dtls_port in your discovery reply, I'd bet on plain working.
Curious to see what the runtime read gives you either way - and it's useful for me to see how a ccm-type module behaves versus the Kit-20.
Ah, that pattern (connects, but every runtime read times out) combined with the remote firmware upgrade is a strong clue, and honestly it makes me walk back my earlier "probably plain UDP" guess. I think DTLS is back on the table for you.
Here's why the two-stage behaviour fits: the library does discovery on UDP/48899, which always answers in plain text - that's your successful "Connecting… Connected!". But the actual runtime data is Modbus on UDP/8899, a separate channel. If GoodWe's remote firmware upgrade switched that channel to DTLS, you'd see exactly this: connect succeeds, then every data read fails after 3 retries. It's the same symptom I hit on my own dongle before adding DTLS support. (The discovery reply not showing dtls_port may just be a quirk of the ccm module's reply format — the data port can still require DTLS regardless.)
So I'd test the DTLS path next. Install my branch of the library:
inv = await goodwe.connect("<dongle_ip>", family="ES", dtls=True)
data = await inv.read_runtime_data()
If runtime reads start returning data with dtls=True, that confirms the firmware upgrade moved you onto DTLS — and it's another data point that GoodWe is pushing this out to more module types than just the Kit-20. If it still fails even with DTLS, then it's more likely a ccm/ES register-block mismatch and we'd dig into which Modbus range actually responds.
Fingers crossed it's the former — curious to see what you get.
That result is genuinely useful, because it more or less rules DTLS out. If the data channel needed encryption, the DTLS attempt would have behaved differently from the plain one. Getting the identical "no valid response after 3 retries" from both suggests the problem isn't the transport at all — it's more likely the library is speaking the wrong dialect to your module.
Here's my thinking: the library's family="ES" targets the original first-gen ES inverters, which use GoodWe's older AA55 protocol. Your GW6000-ES-20 is a newer "G2 / -20" hybrid, and those often actually speak the ET-style Modbus protocol under the hood rather than legacy ES. So forcing family="ES" may be sending a command your inverter doesn't recognise — the discovery probe answers (that's a generic dongle-level thing), but the inverter-level data read goes unanswered.
Two quick things worth trying (about a minute each):
Let the library auto-detect instead of forcing the family: inv = await goodwe.connect("<dongle_ip>") # no family=
If one of those returns data, that was it — a family mismatch, not encryption.
If they all still fail, the definitive next step is to see what the official app actually does. Since it works when your phone is on the local Wi-Fi, a quick Wireshark capture (or a pcap on your router) of the app pulling live data would show the exact port, whether it's encrypted, and the framing — and then we can match it. Happy to walk you through that if we get there.
Feel free to use this result to validate or document support for this inverter model if it's useful for the project.
Connecting to inverter with DTLS...
Connected!
===== GOODWE DATA =====
timestamp 2026-07-05 16:31:34
vpv1 238.6 V
ipv1 0.5 A
ppv1 134 W
vpv2 191.4 V
ipv2 0.4 A
ppv2 87 W
ppv 221 W
pv2_mode 2
pv2_mode_label PV panels connected, producing power
pv1_mode 2
pv1_mode_label PV panels connected, producing power
vgrid 235.3 V
igrid 3.4 A
fgrid 50.02 Hz
pgrid 787 W
grid_mode 1
grid_mode_label Connected to grid
total_inverter_power 794 W
active_power -3 W
grid_in_out 0
grid_in_out_label Idle
reactive_power 6 var
apparent_power 787 VA
backup_v1 234.5 V
backup_i1 0.8 A
backup_f1 50.02 Hz
load_mode1 1
backup_p1 111 W
load_p1 677 W
backup_ptotal 111 W
load_ptotal 685 W
ups_load 0 %
temperature_air 54.6 C
temperature_module 0.0 C
temperature 46.7 C
function_bit 0
bus_voltage 366.0 V
nbus_voltage 0 V
vbattery1 52.9 V
ibattery1 12.2 A
pbattery1 646 W
battery_mode 2
battery_mode_label Discharge
warning_code 0
safety_country 0
safety_country_label IT CEI 0-21
work_mode 1
work_mode_label Normal (On-Grid)
operation_mode 512
error_codes 0
errors
e_total 9516.0 kWh
e_day 26.9 kWh
e_total_exp 8537.2 kWh
h_total 11526 h
e_day_exp 23.1 kWh
e_total_imp 34.5 kWh
e_day_imp 0.0 kWh
e_load_total 4283.4 kWh
e_load_day 16.8 kWh
e_bat_charge_total 961.3 kWh
e_bat_charge_day 3.2 kWh
e_bat_discharge_total 955.5 kWh
e_bat_discharge_day 2.2 kWh
diagnose_result 320
diagnose_result_label Discharge Driver On, APP: Discharge current too low
house_consumption 870 W
commode 7
rssi 0
manufacture_code 10
meter_test_status 0
meter_comm_status 1
active_power1 -1 W
active_power_total -1 W
reactive_power_total -33 var
meter_power_factor1 -0.006
meter_power_factor -0.03
meter_freq 50.03 Hz
meter_e_total_exp 5677.42 kWh
meter_e_total_imp 1242.83 kWh
meter_active_power1 -1 W
meter_active_power_total -1 W
meter_reactive_power1 -33 var
meter_reactive_power_total -33 var
meter_apparent_power1 -183 VA
meter_apparent_power_total -33 VA
meter_type 0
meter_sw_version 4
meter2_active_power 0 W
meter2_e_total_exp 0.0 kWh
meter2_e_total_imp 0.0 kWh
meter2_comm_status 0
meter_voltage1 235.7 V
meter_current1 0.7 A
meter_e_total_exp1 5677.42 kWh
meter_e_total_imp1 1242.83 kWh
battery_bms 1
battery_index 184
battery_status 1
battery_temperature 36.4 C
battery_charge_limit 10 A
battery_discharge_limit 100 A
battery_error_l 0
battery_soc 98 %
battery_soh 99 %
battery_modules 1
battery_warning_l 0
battery_protocol 293
battery_error_h 0
battery_error
battery_warning_h 0
battery_warning
battery_sw_version 257
battery_hw_version 0
battery_max_cell_temp_id 289
battery_min_cell_temp_id 785
battery_max_cell_voltage_id 3617
battery_min_cell_voltage_id 529
battery_max_cell_temp 26.5 C
battery_min_cell_temp 25.4 C
battery_max_cell_voltage 3.31 V
battery_min_cell_voltage 3.3080000000000003 V
ppv_total None W
pv_channel 0
vpv5 0 V
ipv5 0 A
vpv6 0 V
ipv6 0 A
vpv7 0 V
ipv7 0 A
vpv8 0 V
ipv8 0 A
vpv9 0 V
ipv9 0 A
vpv10 0 V
ipv10 0 A
vpv11 0 V
ipv11 0 A
vpv12 0 V
ipv12 0 A
vpv13 0 V
ipv13 0 A
vpv14 0 V
ipv14 0 A
vpv15 0 V
ipv15 0 A
vpv16 0 V
ipv16 0 A
pmppt1 None W
pmppt2 None W
pmppt3 None W
pmppt4 None W
pmppt5 None W
pmppt6 None W
pmppt7 None W
pmppt8 None W
imppt1 0 A
imppt2 0 A
imppt3 0 A
imppt4 0 A
imppt5 0 A
imppt6 0 A
imppt7 0 A
imppt8 0 A
reactive_power1 -1 var
reactive_power2 -1 var
reactive_power3 -1 var
apparent_power1 -1 VA
apparent_power2 -1 VA
apparent_power3 0 VA
C:\Users\Win-Tas>
Now that everything is working, I'm planning to integrate the inverter into my PoolGardenController.
At the moment, my controller updates its sensors every 30 seconds.
In your experience, what polling interval would you recommend for the GoodWe dongle?
Would 30 seconds be considered a safe long-term interval?
Or could it comfortably handle polling every 10 or 15 seconds without causing unnecessary load on the inverter or communication module?
I'm not looking for the fastest possible updates; I'd simply like to choose a sensible interval that is reliable and doesn't stress the dongle over time.
Fantastic result and thank you, this is genuinely useful to validate the model. That’s a complete ET-family dataset coming through the DTLS branch (PV both strings, battery, discharging, backup/UPS load, dual meters, right down to per-cell BMS voltages), so the confirmed recipe for the GW6000-ES-20 is: family=“ET” + dtls=True on the DTLS branch. I’ll add your unit to the PR’s validated models list - it’s a nice data point that the ES-20 hybrids speak the ET protocol over DTLS.
On polling - short version: 30s is very safe, and 10-15s is perfectly comfortable too. The interval itself isn’t really what stresses the dongle; what matters is how you connect:
Keep one persistent DTLS session open and reuse it for every poll - don’t do a fresh connect()/handshake each cycle. The handshake (~1s) is the only expensive part; once the session is up, each read is just ~100-250ms of Modbus. Only reconnect when a read actually errors.
Do that and:
The dongle runs its own DTLS heartbeat roughly every 4s to keep the session alive, so any interval from ~1s up to 30s sits comfortably inside that - the session stays warm and you never force a re-handshake.
30s is conservative and ideal for long-term reliability (and it lines up with your existing PoolGardenController sensors).
10-15s adds no meaningful load if you’d like the solar/battery figures to feel more live. Both are fine long-term.
One caveat: my own long-duration soak testing was on a different dongle model, so I can’t personally promise “flawless at 10s for years” on your exact unit - but the heartbeat behaviour is common across these dongles, so I’d be surprised if 15-30s ever caused trouble. Since it’s UDP under the hood, just have your code treat the occasional dropped read as “catch and reconnect” rather than fatal, and it would be rock-solid.
For your use case I’d keep 30s (or 15s if you want it livelier) - no need to push harder than that.