GoodWe GW6000-ES-20: Read real-time PV production locally (Python, no cloud)

Hello everyone,

I developed an open-source Python project called PoolGardenController.

It is a web-based application that automates my swimming pool and garden, including:

• Water temperature monitoring
• Relay controllers
• Automatic pool heating
• Telegram notifications

The next step is integrating my GoodWe GW6000-ES-20 inverter to optimize pool heating using the available solar power.

I don't need cloud access or SEMS.

I only need to read the current PV production (Watts) from the inverter over the local network approximately every 30 seconds.

My questions are:

  1. Is there an official local API available?
  2. Does the inverter support Modbus TCP?
  3. Are there documented Modbus registers for the current PV production?
  4. Is there another recommended local protocol?

I already have the Python software working, so I'm only looking for the best way to read the inverter data locally.

Any documentation, examples, or suggestions would be greatly appreciated.

Thank you very much!

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:

  1. 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.
  2. 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!

One more question, if you don't mind.

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.

Thank you for the additional explanation.

I ran the discovery probe over my site-to-site IPsec VPN.

The good news is that the dongle responded correctly through the VPN.

The reply I received was:

ccm@sn,ccm@sn,Solar-56000ESN243xxxxx

(I've redacted the last digits of the serial number.)

I did not receive a response containing "dtls_port:8899" as you mentioned.

Does this indicate a different firmware version, or should I use a different discovery command?

Interestingly, this also confirms that UDP communication to the dongle works correctly over an IPsec VPN between the two LANs.

Thank you again for your help!

That's actually a really helpful result — two takeaways:

  1. 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.

  2. 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.

Good news!

I tested the standard goodwe library exactly as you suggested.

Results:

Connecting to inverter...
Connected!

So the library successfully reaches the inverter over my IPsec VPN.

However,

read_runtime_data() fails with:

RequestFailedException
('No valid response received even after 3 retries', 1)

This seems to suggest that connectivity is working correctly, but the runtime request itself is not receiving a valid response.

Do you think this points to DTLS after all, despite the discovery reply not containing dtls_port?

Or could this be another CCM-specific protocol difference?

GoodWe support remotely upgraded my inverter firmware some time ago, so it’s possible I’m running a newer firmware than expected.

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:

pip install "git+https://github.com/botts7/goodwe.git@feature/dtls-transport"
pip install "pyOpenSSL>=24.0"

then the same script but with dtls=True:

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.

Thanks for your suggestion.

I tested your DTLS branch.

Environment:

  • GW6000-ES-20
  • CCM communication module
  • GoodWe support remotely upgraded my inverter firmware some time ago
  • Site-to-site IPsec VPN (works correctly)

The branch installed correctly:

goodwe 0.0.0.dev0

pyOpenSSL is also installed.

Results:

Standard library:

Connecting to inverter...
Connected!

read_runtime_data() ->
RequestFailedException
('No valid response received even after 3 retries', 1)

DTLS branch:

Connecting to inverter with DTLS...

RequestFailedException
('No valid response received even after 3 retries', 1)

So:

• discovery works
• UDP routing works
• the inverter is reachable
• plain runtime fails
• DTLS runtime also fails

I'm wondering if the firmware upgrade performed by GoodWe changed something else in the protocol.

What do you think would be the next diagnostic step?

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):

  1. Let the library auto-detect instead of forcing the family:
    inv = await goodwe.connect("<dongle_ip>") # no family=

Or explicitly try ET:

  1. inv = await goodwe.connect("<dongle_ip>", family="ET")

(try each both with and without dtls=True)

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.

Success!!

Bingo! That was exactly it.

Removing the forced family selection solved the problem.

Using your DTLS branch with automatic family detection, the connection succeeds and all runtime data is read correctly.

My setup is:

  • GoodWe GW6000-ES-20
  • CCM communication module
  • Firmware remotely updated by GoodWe support
  • Site-to-site IPsec VPN
  • DTLS transport
  • No family specified (auto detection)

This also confirms that DTLS communication works correctly over an IPsec VPN.

I've attached the complete runtime output in case it's useful for validating support for this combination of hardware and firmware.

Thank you very much for your patience and for all your help. I really appreciate it!

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>

One additional question, if you don't mind.

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.