Skip to content

Add DTLS transport for newer Wi-Fi/LAN Kit-20 firmware#136

Open
botts7 wants to merge 1 commit into
marcelblijleven:masterfrom
botts7:feature/dtls-transport
Open

Add DTLS transport for newer Wi-Fi/LAN Kit-20 firmware#136
botts7 wants to merge 1 commit into
marcelblijleven:masterfrom
botts7:feature/dtls-transport

Conversation

@botts7

@botts7 botts7 commented May 25, 2026

Copy link
Copy Markdown

Newer Wi-Fi/LAN Kit-20 dongles (firmware released ~2024 onward — referred to in #117 and #121 as the "Cyber Security dongle") encrypt their local Modbus protocol with DTLS over UDP/8899. The standard WIFIKIT-214028-READ discovery probe to UDP/48899 returns dtls_port:8899 for affected hardware; plaintext requests on UDP/8899 are silently dropped, which is why UdpInverterProtocol times out against them without a useful diagnostic.

Empirically observed handshake parameters

  • DTLS 1.2 negotiated by default with OpenSSL 3.0+ defaults (cipher ECDHE-RSA-AES256-GCM-SHA384). DTLS 1.0 also accepted.
  • The dongle presents a self-signed placeholder certificate (CN=broker.goodwe-power.com), so peer verification must be disabled.
  • Appears to accept only one concurrent DTLS session.

The application protocol is unchanged — Modbus RTU framing, slave 0xF7, identical register layout to existing inverter families. Responses carry the same AA55 prefix that validate_modbus_rtu_response already handles. Only the transport differs.

Implementation

  • New goodwe/dtls.py with DTLSInverterProtocol, an async wrapper around pyOpenSSL's DTLS_METHOD driven by memory BIOs over a connected UDP socket. Reuses the existing command/decode infrastructure unchanged.
  • Opt-in via a new dtls=True argument to goodwe.connect() (and the family classes). The goodwe.dtls module imports pyOpenSSL only on use, so the library still loads when the optional dependency isn't installed.
  • Adds a [dtls] extra in setup.cfg (pip install goodwe[dtls]).
  • Sessions are reused (keep_alive=True) so the ~1 s handshake is paid once per Inverter. Stale-response retries preserve the session; only genuine I/O errors tear it down.
inverter = await goodwe.connect(ip, family="ET", dtls=True)

Auto-discovery remains plaintext-only, so the family must be specified explicitly when dtls=True.

Tested — three inverters across two families

Inverter Family Reporter
GW10K-MS-30 MS (single-phase) author
GW15K-ET-20 ET (three-phase) @rnagler (#121)
GW10K-ET-20 ET (three-phase) @adamsteen (#121)

Both ET-family users independently confirmed: discovery returns dtls_port:8899, and connecting with family="ET", dtls=True works where plaintext timed out.

Compatibility

  • All 122 existing tests pass unchanged.
  • 10 new tests in tests/test_dtls.py cover the async wrapper, retry/lock semantics, stale-response handling, and command-construction parity with the UDP path. They mock the blocking DTLS I/O; the real handshake is exercised by tests/manual_dtls_check.py (excluded from the standard run, needs hardware).

Open question

Happy to switch the dtls=True boolean to a transport= enum if you'd prefer that for forward-compatibility.

Closes #121
Possibly addresses #117, #95, #91

Newer Kit-20 dongle firmware (released ~2024 onward) encrypts the local
Modbus protocol with DTLS over UDP/8899. The standard `WIFIKIT-214028-READ`
discovery probe to UDP/48899 returns `dtls_port:8899` for affected hardware,
while plaintext requests on UDP/8899 are silently dropped — the existing
UdpInverterProtocol times out with no useful diagnostic.

This change adds an opt-in DTLS transport that runs the unchanged Modbus RTU
framing over DTLS. The application protocol (frames, register layout, slave
addresses, AA55 response prefix) is identical to the plaintext path; only
the transport differs.

Empirically observed handshake parameters:
  - DTLS 1.2 negotiated by default with OpenSSL 3.0+ defaults
    (cipher ECDHE-RSA-AES256-GCM-SHA384). DTLS 1.0 also accepted.
  - Self-signed placeholder certificate, so peer verification is disabled.
  - Appears to accept only one concurrent DTLS session.

Sessions are reused via keep_alive=True so the ~1 s handshake is paid once
per Inverter instance. On a stale-response retry the session is preserved
and the drain at the start of the next send absorbs any leftover data; only
real I/O / SSL exceptions tear the session down.

Usage:
    inverter = await goodwe.connect(ip, family="DT", dtls=True)

Requires pyOpenSSL — installed via the new `[dtls]` extra. Auto-discovery
remains plaintext-only, so the family must be specified explicitly when
dtls=True.

Tested on a single-phase MS-family inverter (GW10K-MS-30) running a Kit-20
dongle whose discovery response includes `dtls_port:8899`. Existing 122
tests still pass; 10 new tests cover the DTLS transport's command
construction, retry/lock semantics, stale-response handling, and lifecycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Problems with GW15k-ET-20 (Cyber Security dongle)

1 participant