Skip to content

Provisional support for TL-XH2 (and maybe TL-XH ?) inverters - #185

Merged
robertzaage merged 4 commits into
robertzaage:mainfrom
naps62:xh2-support
May 19, 2026
Merged

robertzaage merged 4 commits into
robertzaage:mainfrom
naps62:xh2-support

Conversation

@naps62

@naps62 naps62 commented May 19, 2026

Copy link
Copy Markdown
Contributor

Notice: This was built with heavy claude code assistance (for development, for parsing tcpdump pcap logs, and for debugging mqtt connections).

I recently got a Growatt inverter installed. originally my quick research told me it would be a somewhat friendly brand to homeassistant. oh boy, it wasn't. seems my dongle had some issues that other models didn't suffer from, but I finally got a working setup

Inverter model: 4200TL-XH2
dongle: ShineWifi-X2 (connected via wifi)

TL;DR of what I faced:

  • Luckily the ability to change mqtt server in the ShinePhone app still works for my dongle. Also the same date-based password for advanced options.
  • I have a mosquitto server running, but the dongle couldn't connect to it. After debugging, it seems like the dongle's SUBSCRIBE topic contains a weird character at the end (e.g.: s/33/ZGQ0F5601J?\x18). This required a small patch in grobro, and also caused mosquitto (both 3.x and 2.x to fail and refuse that connection due to malformed data). Luckily exmq accepted it
  • For the registers file here, I mostly copied the ones from the NEO family. So far it seems to work (I have correct values on HA already, will be double checking them against ShinePhone over the next few days). However, with the caveat that I don't yet have a battery, so I can't be sure those are working yet

Original PR summary from claude

Details

Changes:

  • Replace the isprintable() device_id extractor with an alphanumeric-only
    regex. Growatt serials are exclusively A-Z 0-9, so this is a safe
    tightening for all device families (still passes the existing test
    suite untouched). Centralize the logic in _extract_device_id().

  • Add a KNOWN_XH_REGISTERS set loaded from
    grobro/model/growatt_xh_registers.json. Provisional: the JSON is
    currently a copy of the NEO register map as a starting point.
    Actual XH/TL-XH/TL-XH2 register definitions need to be verified
    against live data — likely closer to SPH (single-phase hybrid)
    semantics from Grott's legacy protocol definitions.

  • Route the ZGQ prefix to KNOWN_XH_REGISTERS in both
    grobro/grobro/client.py (modbus + config message routing) and
    grobro/ha/client.py (get_known_registers, get_device_type_name
    returns "MIN-XH").

  • Add tests for _extract_device_id covering the QMN/0PVP/ZGQ cases
    and the trailing-control-char + ? quirks.

Tested with: MIN 4200TL-XH2 inverter, ShineWiFi-X2 dongle,
firmware sw_version=7.6.1.8. Dongle connects via TLS (port 7006 on EMQX),
SUBSCRIBE accepted, NOAH-style config message recognized
(Received NOAH config for ZGQ0F5601J sw_version=7.6.1.8), and the
device is now matched by ZGQ-prefix routing. The exact register layout
for XH2 modbus payloads still needs validation; this commit unblocks
the pipeline so users can iterate on register definitions.

Refs: discussion in johanmeijer/grott#592 (XH dongle protocol change)

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

My docker current compose

Details

the target mqtt (10.6.10.12) is hosted separately from this

services:
  emqx-growatt:                                                                                         
    image: emqx/emqx:5.8                                                                                
    container_name: emqx-growatt                                                                        
    restart: unless-stopped
    ports:
      - "7006:7006/tcp"                                 
      - "18083:18083/tcp"     # web dashboard
    configs:
      - source: emqx_conf
        target: /opt/emqx/etc/emqx.conf
    volumes:
      - emqx-data:/opt/emqx/data
      - emqx-certs:/opt/emqx/etc/certs

  grobro:
    build:                                                                                                
      context: https://github.com/naps62/GroBro.git#xh2-support
    container_name: grobro
    restart: unless-stopped
    volumes:
      - grobro-dumps:/dump
    depends_on:
      #- mosquitto-growatt
      - emqx-growatt
    environment:
      # SOURCE: the dedicated dongle broker (container-to-container)
      SOURCE_MQTT_HOST: "emqx-growatt"
      SOURCE_MQTT_PORT: "1883"
      SOURCE_MQTT_TLS:  "false"
      # TARGET: real Mosquitto on the LXC
      TARGET_MQTT_HOST: "10.6.10.12"
      TARGET_MQTT_PORT: "1883"
      TARGET_MQTT_TLS:  "false"
      TARGET_MQTT_USER: "grobro"
      TARGET_MQTT_PASS: "${GROBRO_TARGET_PASS}"
      # No cloud forwarding — local only
      GROWATT_CLOUD:    "true"
      GROWATT_CLOUD_CONFIG_FILTER: "true"
      LOG_LEVEL:        "DEBUG"
      DUMP_MESSAGES: "true"
      
      #DEVICE_TIMEOUT:   "300"
      #AVAILABILITY_SENSOR: "true"

configs:
  emqx_conf:
    content: |
      node {
        name = "emqx@127.0.0.1"
        cookie = "growatt-secret-cookie"
        data_dir = "/opt/emqx/data"
      }

      listeners.ssl.default {
        bind = "0.0.0.0:7006"
        enable = true
        ssl_options {
          keyfile = "/opt/emqx/etc/certs/privkey.pem"
          certfile = "/opt/emqx/etc/certs/fullchain.pem"
          verify = verify_none
          fail_if_no_peer_cert = false
        }
      }

      listeners.tcp.default {
        bind = "0.0.0.0:1883"
        enable = true
      }

      listeners.ws.default.enable = false
      listeners.wss.default.enable = false

      authentication = []
      authorization {
        no_match = allow
        deny_action = ignore
        sources = []
      }

volumes:
  emqx-data:
  emqx-certs:
  grobro-dumps:

HA screenshot (partial, too many entities to show)

image

naps62 and others added 3 commits May 18, 2026 15:55
The ShineWiFi-X2 dongle used by Growatt's TL-XH hybrid inverter family
produces two issues with the current GroBro:

1. The SUBSCRIBE topic from the dongle contains stray trailing bytes
   (observed: `s/33/ZGQ0F5601J?\x18`). The existing isprintable()-only
   filter strips the 0x18 control character but keeps the `?`, producing
   a device_id like `ZGQ0F5601J?` that doesn't match any prefix.

2. The ZGQ serial prefix isn't recognized as a known device family,
   leading to "Modbus message from unknown device type" log spam and
   no HA entities being created.

Changes:

- Replace the `isprintable()` device_id extractor with an alphanumeric-only
  regex. Growatt serials are exclusively A-Z 0-9, so this is a safe
  tightening for all device families (still passes the existing test
  suite untouched). Centralize the logic in `_extract_device_id()`.

- Add a `KNOWN_XH_REGISTERS` set loaded from
  `grobro/model/growatt_xh_registers.json`. Provisional: the JSON is
  currently a copy of the NEO register map as a starting point.
  Actual XH/TL-XH/TL-XH2 register definitions need to be verified
  against live data — likely closer to SPH (single-phase hybrid)
  semantics from Grott's legacy protocol definitions.

- Route the ZGQ prefix to KNOWN_XH_REGISTERS in both
  `grobro/grobro/client.py` (modbus + config message routing) and
  `grobro/ha/client.py` (`get_known_registers`, `get_device_type_name`
  returns "MIN-XH").

- Add tests for `_extract_device_id` covering the QMN/0PVP/ZGQ cases
  and the trailing-control-char + `?` quirks.

Tested with: MIN 4200TL-XH2 inverter, ShineWiFi-X2 dongle,
firmware sw_version=7.6.1.8. Dongle connects via TLS (port 7006 on EMQX),
SUBSCRIBE accepted, NOAH-style config message recognized
(`Received NOAH config for ZGQ0F5601J sw_version=7.6.1.8`), and the
device is now matched by ZGQ-prefix routing. The exact register layout
for XH2 modbus payloads still needs validation; this commit unblocks
the pipeline so users can iterate on register definitions.

Refs: discussion in johanmeijer/grott#592 (XH dongle protocol change)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The NEO-as-XH register copy already contained 106 fields with HA discovery
metadata that were just gated behind publish:false. Verified against live
data from a Growatt MIN 4200TL-XH2 that these registers decode correctly:

  Grid power (live, W):
    Ptouser_total → "Grid import power"
    Ptogrid_total → "Grid export power"
    Ptoload_total → "Load power"

  Grid energy (cumulative, kWh):
    Etouser_today / Etouser_total → "Grid import today / Lifetime"
    Etogrid_today / Etogrid_total → "Grid export today / Lifetime"
    Eload_today   / Eload_total   → "Load consumption today / Lifetime"

These are the fields needed for HA's Energy Dashboard.

Also enabled battery + BMS fields. They will read 0 until a battery is
physically installed (no-battery configuration confirmed at test setup).
Register positions inherited from NEO; verification against a real XH
battery installation is still pending — values may need tweaking but the
HA entities will at least exist:

  Battery state:
    Vbat, Ibat, SOC, Pchr, Pdischr
    Echr_today/total, Edischr_today/total, Eacchr_today/total

  BMS:
    BMS_Status, BMS_SOC, BMS_BatteryVolt, BMS_BatteryCurr,
    BMS_BatteryTemp, BMS_SOH, BMS_CycleCnt

  BDC: BDC_OnOffState

  Diagnostics:
    Inverter_Status, Fault_code, Warning_code, N_Bus_Voltage, Time_total

Friendly names rewritten where the original was ambiguous
("Total forward power" → "Grid import power", etc.).

Test suite passes unchanged (342 tests).

The enabling script (scripts/enable_xh_entities.py) is checked in so the
mapping is reviewable and reusable when more fields are validated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous naming implied this code supports the entire MIN TL-XH
family, but:
- XH gen 1 uses the legacy port-5279 protocol handled by Grott, not GroBro
- XH gen 2 uses the new encrypted MQTT-on-7006 protocol (this fork)
- The ZGQ serial prefix and the ShineWiFi-X2 dongle are gen-2 specific

We only verified against a MIN 4200TL-XH2 inverter, so the constants/files
should reflect that scope.

  KNOWN_XH_REGISTERS         → KNOWN_XH2_REGISTERS
  growatt_xh_registers.json  → growatt_xh2_registers.json
  enable_xh_entities.py      → enable_xh2_entities.py
  get_device_type_name "MIN-XH" → "MIN-XH2"

No functional changes; tests still pass (342).

If someone later wants to add XH gen 1 support, that would be a separate
register set (likely closer to Grott's SPH layout) and prefix, not a
rename of this one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@robertzaage robertzaage self-assigned this May 19, 2026
@robertzaage robertzaage added the enhancement New feature or request label May 19, 2026
Repository owner deleted a comment from codecov-commenter May 19, 2026
@robertzaage

Copy link
Copy Markdown
Owner

Thank you very much for this very valuable contribution! 😊

@robertzaage
robertzaage marked this pull request as ready for review May 19, 2026 21:44
@codecov-commenter

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

ℹ️ You can also turn on project coverage checks and project coverage reporting on Pull Request comment

Thanks for integrating Codecov - We've got you covered ☂️

@robertzaage
robertzaage merged commit 374061b into robertzaage:main May 19, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants