- Architecture Overview
- Message Protocol
- Register System
- Extending GroBro
- HA MQTT Discovery
- Testing
- CI / CD and Tooling
- Debugging
GroBro is a bidirectional MQTT bridge between Growatt energy devices and Home Assistant. It receives encrypted telemetry from inverters and batteries, descrambles and decodes the binary protocol, then republishes structured data for Home Assistant MQTT auto-discovery. Commands flow in the opposite direction: a user toggles a switch in HA and GroBro translates it back into a binary MQTT packet the device understands.
graph TD
subgraph "Growatt Devices"
NEO["NEO Inverter<br/>(QMN serial)"]
NOAH["NOAH Battery<br/>(0PVP serial)"]
NEXA["NEXA Battery<br/>(0HVR serial)"]
SPF["SPF Inverter<br/>(HAQ serial)"]
SWX["ShineWeLink<br/>(RAQ serial)"]
NEO_L["NEO 1000M-X<br/>(PTQ serial, via LoRa)"]
end
subgraph "Source MQTT"
SRC_BROKER["Mosquitto"]
end
subgraph "GroBro Bridge"
GC["grobro.Client<br/>descramble + route"]
HC["ha.Client<br/>discovery + state"]
end
subgraph "Target MQTT (port 1883)"
TGT_BROKER["Mosquitto"]
end
subgraph "Home Assistant"
HA["MQTT Auto-Discovery"]
end
NEO --> SRC_BROKER
NOAH --> SRC_BROKER
NEXA --> SRC_BROKER
SPF --> SRC_BROKER
SWX --> NEO_L --> SRC_BROKER
SRC_BROKER --> GC
GC --> HC
HC --> TGT_BROKER
TGT_BROKER --> HA
HA -.->|commands| HC -.->|forward| GC -.-> SRC_BROKER
When a ShineWeLink data logger (RAQ serial) is present, it bridges a NEO 1000M-X inverter (PTQ serial) over LoRa radio. The data logger has its own wifi connection to the source MQTT broker, and the inverter communicates with the logger wirelessly. This means:
- The MQTT topic contains the data logger serial (
c/33/RAQ0E8H042). - The FE19 config TLV contains the data logger's own serial — not the inverter's.
- NEO input/holding register data is wrapped in NOAH
0x0103frames within the data logger's messages. - The RAQ prefix routes through NEO register files via
get_known_registers(). The PTQ prefix (NEO 1000M-X) is only handled ingrobro/client.pyconfig message processing, not inha/client.pyregister routing.
| Layer | Module | Role |
|---|---|---|
| Bridge wiring | grobro/ha_bridge.py |
Instantiates both clients, wires callbacks, runs the event loop |
| Device comms | grobro/grobro/client.py |
Subscribes to c/#, descrambles, dispatches by message type, sends commands on s/33/ |
| Parsing | grobro/grobro/parser.py |
Config TLV parser, NOAH sub-parsers, unscramble |
| Packet building | grobro/grobro/builder.py |
Scramble, CRC-16 append, hexdump |
| HA integration | grobro/ha/client.py |
MQTT auto-discovery, state publishing, command handling, config read sequencing |
| Registers | grobro/model/*.json + growatt_registers.py |
Device register maps loaded from JSON at import time |
| Models | grobro/model/*.py |
Pydantic models for device config, modbus messages, MQTT config |
Device → Source MQTT → grobro.Client.__on_message
├─ unscramble(msg.payload)
├─ detect message type from header
├─ parser.parse_*(unscrambled)
└─ call ha.Client callback
├─ publish_input_register(state)
├─ publish_holding_register_input(state)
└─ set_config(device_id, config)
└─ __publish_device_discovery(device_id)
└─ Target MQTT → HA
HA → Target MQTT → ha.Client.__on_message
├─ identify device + register from topic
├─ build modbus / config command
└─ call grobro.Client callback
├─ send_command(msg)
├─ send_config_read_message(device, reg)
└─ send_config_message(device, reg, value)
└─ Source MQTT → Device
Config registers are read sequentially, not in parallel, because the datalogger can only handle one outstanding config read at a time:
read_allbutton handler enqueues all config register numbers (3-second delay before starting to let modbus reads complete).__kickoff_next_config_readpops the next register, sends the read command, and adds an inflight entry to_config_read_inflightwith a 60-second timer.- When the response arrives (
parse_config_message→on_config_read_response→ha.Client.handle_config_read_response), the inflight entry is cleared and the queue advances. - If the timer expires (
_config_read_timeout), the entry is removed, an error is logged, and the queue advances to the next register.
GroBro uses MQTT user properties to tag forwarded messages and prevent infinite loops:
MQTT_PROP_FORWARD_GROWATT # UserProperty: ("forwarded-for", "growatt")
MQTT_PROP_FORWARD_HA # UserProperty: ("forwarded-for", "ha")
MQTT_PROP_DRY_RUN # UserProperty: ("dry-run", "true") — defined but unusedWhen __on_message receives a packet, get_property(msg, "forwarded-for") checks for
these tags. Messages tagged "ha" or "growatt" are skipped to avoid echo loops.
Device configs are cached in memory (_config_cache dict) and also persisted to disk as
config_{device_id}.json files in the working directory. On startup, if a device's config
is not in the cache, __device_info_from_config falls back to loading from file, then to
creating a minimal config with just the serial number. Config files are written via
DeviceConfig.to_file() and read via DeviceConfig.from_file().
- Source broker — where Growatt devices publish. Credentials defined via
SOURCE_*env vars (default:localhost:1883, no TLS). - Target broker (port 1883) — where Home Assistant listens. Credentials via
TARGET_*env vars. - Forward broker (optional,
mqtt.growatt.com:7006) — forwards messages to Growatt Cloud so the ShinePhone app remains functional. Credentials viaFORWARD_*env vars. Forwarding is conditional:GROWATT_CLOUD=trueforwards all devices; a comma-separated serial list forwards only matching devices. WhenGROWATT_CLOUD_CONFIG_FILTER=true, config write messages are blocked from forwarding.
Growatt devices XOR the payload with a static 7-byte key "Growatt". The first 8 bytes of every
packet are a plain-text header and are not scrambled. The rest is XOR'd bytewise.
Bytes [0:8] — header, preserved as-is
Bytes [8:] — XOR'd with repeating key "Growatt" = [0x47, 0x72, 0x6F, 0x77, 0x61, 0x74, 0x74]
scramble() and unscramble() are the same function — XOR is its own inverse.
def unscramble(data: bytes) -> bytes:
mask = b"Growatt"
out = bytearray(data[:8])
out += bytes(b ^ mask[i % len(mask)] for i, b in enumerate(data[8:]))
return bytes(out)Nearly all GroBro messages share a 38-byte header defined by HEADER_STRUCT = ">HHHBB30s":
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 2 | unknown |
Usually 0x006A for NEO, 0x0000 for some NOAH/SPF messages |
| 2 | 2 | constant_7 |
Always 0x0007 |
| 4 | 2 | msg_len |
Length of payload after byte 8 (len(buffer[8:])) |
| 6 | 1 | constant_1 |
Always 0x01 |
| 7 | 1 | function |
Modbus function code (3, 4, 5, 6, 16, 100) |
| 8 | 30 | device_id |
Zero-padded ASCII serial number |
For NEO / NEXA / SPF devices the full header is always present. NOAH and ShineWeLink devices use a different framing (see §2.7).
Dispatch priority in __on_message:
| Priority | Type | Value | Devices | Handler |
|---|---|---|---|---|
| 1 | Config TLV | 340, 341, 387 | NEO, NOAH | parser.parse_config_type → on_config(device_id, config) |
| 2 | Config read response | 281 | All | parser.parse_config_message → on_config_read_response |
| 3 | Config write ack | 280 | All | Logged, no callback |
| 4 | NOAH FE19 config | 0xFE19 | NOAH, NEXA | parser.parse_noah_fe19 → on_config(device_id, config) |
| 5 | ShineWeLink config | 0x0129 (297) | ShineWeLink | parser.parse_config_type → on_config(device_id, config) |
| 6 | Other NOAH subtypes | 0x0103–0xFE25 | NOAH / ShineWeLink | Dispatched to NOAH sub-parsers (see §2.7.2) |
| 7 | Smart Meter JSON | 0x6F64 | NOAH, NEXA | Published to raw MQTT topic |
| 8 | Generic modbus | 3, 4, 5, 6, 16, 100 | All | GrowattModbusMessage.parse_grobro → routed by function code |
The function field at byte 7 determines the modbus function:
- 3 — Read Holding Registers (config/settings, read-write)
- 4 — Read Input Registers (telemetry, read-only)
- 5 — Write Single Coil (on/off control)
- 6 — Write Single Register
- 16 — Write Multiple Registers
- 100 — Config write (vendor-specific function)
Config messages (types 340, 341, 387) and NOAH FE19 config use a Type-Length-Value encoding:
Offset Size Field
0 2 Key ID (big-endian)
2 2 Value length (big-endian)
4 N Value (ASCII or raw bytes)
Key ID map (maintained in parse_config_type):
| Key | Field | Type | Example |
|---|---|---|---|
| 4 | data_interval |
INT string | "1280" (seconds between reports) |
| 8 | serial_number |
ASCII | "QMN000ABC1D2E3FG" |
| 9 | protocol_version |
ASCII | "2.0" |
| 13 | device_type |
INT string | "44" (ShineWeLink), "61" (NOAH battery) |
| 16 | mac_address |
ASCII | "84:f7:03:3a:3a:xx" |
| 20 | model_id |
ASCII | "GTSW0000" (ShineWeLink), "MIN-XH" (NEO) |
| 21 | sw_version |
ASCII | "7.7.1.0" |
| 22 | hw_version |
ASCII | "V1.0" |
| 76 | wifi_signal |
INT string | "-67" (dBm) |
| 131 | data_end_hour |
INT string | "23" (data collection window end) |
Values that cannot be decoded as clean ASCII are stored as raw hex strings.
MAC addresses with masked octets (XX) are rejected — all NOAH devices report the
same non-unique masked MAC, which would cause HA to merge them into one device.
Parsed with config_read_struct = Struct(">4sHH16s14sH1xH2x") (45 bytes):
Offset Size Field
0 4 Header (4s)
4 2 Message length (H)
6 2 Message type (H = 281)
8 16 Device ID (16s, zero-padded ASCII)
24 14 Config type (14s, zero-padded ASCII, e.g. "GROWATT_PARAM_1")
38 2 Unknown (H, parsed but unused)
40 1 Padding (1x)
41 2 Register no. (H)
43 2 CRC / padding (2x, discarded)
45 N Value (ASCII, trailing 2 CRC bytes stripped: `data[45:-2]`)
After the 38-byte header, modbus messages contain one or more register blocks:
Offset Size Field
0 2 Start register number (H)
2 2 End register number (H)
4 M×2 Register values (M = end − start + 1, each value is 2 bytes big-endian)
Input register messages (function 4) also include a 37-byte GrowattMetadata block between
the header and the first register block:
Offset Size Field
0 30 Device serial (zero-padded ASCII)
30 7 Timestamp: year-2000, month, day, hour, minute, second, millis (each 1 byte)
NOAH batteries (0PVP) use a different framing on top of the standard 8-byte header. The payload always begins with 14 zero bytes.
[0:8] — standard header (msg_type at offset 4, NOAH-specific value)
[8:24] — device serial (16B, zero-padded ASCII)
[24:38] — 14 zero bytes (NOAH marker)
[38:40] — NOAH subtype / message type (big-endian)
[40:] — type-specific payload
| Subtype | Parser | Description |
|---|---|---|
0x0103 |
parse_noah_0103 |
Encapsulated NEO holding registers (ShineWeLink LoRa bridge) |
0x0110 |
parse_noah_0110 |
Preset-multiple register response |
0x0125 |
parse_noah_0125 |
Serial number query response |
0xFE18 |
parse_noah_fe18 |
Datetime set command / response |
0xFE19 |
parse_noah_fe19 |
Device config (subtype 0x0020) or status (subtype 0x0001) |
0xFE25 |
parse_noah_fe25 |
Heartbeat / keepalive (all zeros) |
0x6F64 |
parse_noah_6f64 |
Smart Meter (EcoTracker, Shelly etc.) JSON data |
FE19 is the most complex NOAH message type. The payload after the NOAH marker is:
[0:2] — subtype: 0x0020 = full config, 0x0001 = dev status
[2:4] — padding (usually 0x0000)
[4:] — TLV entries (see §2.4)
- Full config (subtype
0x0020): Contains serial_number, device_type, model_id, sw_version, hw_version, mac_address, and other TLV keys. Triggerson_config(device_id, config). - Dev status (subtype
0x0001): A shorter TLV without the serial_number key. Theif config and config.serial_numberguard preventson_configfrom being called.
Only NOAH (serial prefix 0PVP) and NEXA devices (serial prefix 0HVR) reach this handler — RAQ FE19 messages
fall through to the modbus handler instead.
TLV offset heuristic (find_config_offset): Scans the payload starting at byte 0x1C
for a valid key (1–999) followed by a valid length (1–255).
When a ShineWeLink bridges a NEO 1000M-X inverter over LoRa, NEO function-3 (holding
register) responses are wrapped in a NOAH 0x0103 frame:
[24:38] — 14 zero bytes
[38:40] — 0x0103
[40:54] — 14 zero bytes (NOAH sub-payload header)
[54:70] — NEO device serial (16B zero-padded)
[70:] — raw register values (2B each, big-endian)
The parse_noah_0103 parser extracts the inner serial and register values into a dict,
but the parsed values are not published to HA — the dict is returned but discarded in
__on_message (NOAH dispatch falls through, then parse_grobro returns None on the
NOAH-wrapped data). This is a known area for future improvement.
The ShineWeLink data logger (RAQ) publishes its full device configuration as
message type 0x0129 (297) at header offset 6, with function byte 0x29 (41)
at offset 7. This is the only RAQ config path — the NOAH FE19 handler rejects
RAQ-sourced messages via the 0PVP prefix guard:
Offset Size Field
0 8 Standard header (msg_type=0x0129 at offset 6, function=0x29 at offset 7)
8 30 Data logger serial (RAQ, zero-padded ASCII)
38 30 Inverter serial (PTQ, zero-padded ASCII)
68 30 Inverter serial repeated
98+ N TLV config entries (see §2.4) — parsed via `find_config_offset` + `parse_config_type`
Device identity: GroBro uses the MQTT topic serial (c/33/<serial>) as the HA device
identifier, not the serial inside the config TLV payload. For ShineWeLink, the topic contains
the data logger serial (RAQ prefix). The 0x0129 config also extracts the PTQ inverter serial
and registers it as a separate NEO device in HA with modbus data routed to it.
NEO inverters (QMN prefix) are the most common device type. They use the standard 38-byte header with no additional framing:
-
Config messages (types 340/341/387) arrive on separate MQTT messages from register data. These are TLV-encoded and trigger
on_config()for HA discovery. -
Holding register writes use modbus function 6 (single) or 16 (multiple). GroBro builds the command via
make_modbus_command()inha/client.pyor directly viaClient.send_command()/Client.send_config_message()ingrobro/client.py. -
CRC: NEO messages include a 2-byte CRC-16 (Modbus variant) at the end of every payload.
parse_grobrovalidates this before parsing registers. -
PV input count: NEO inverters come in 2-input (NEO 800/1000M-X) and 4-input (NEO 2000) variants. GroBro detects the hardware variant at runtime by comparing total PV power against the sum of individual MPPT powers:
# In ha/client.py __detect_neo_pv_count(): if abs(Ppv - (Ppv1+Ppv2+Ppv3+Ppv4)) < 10 and (Ppv3 > 0 or Ppv4 > 0): count = 4 # NEO 2000 elif abs(Ppv - (Ppv1+Ppv2)) < 10: count = 2 # NEO 800 / 1000M-X
Detection runs once per device on the first payload with
Ppv > 0(i.e. daylight hours). The result is cached inClient._neo_pv_countand persists for the lifetime of the process. Until detection fires, PV3/PV4 sensors remain hidden. Once detected as a 4-input inverter, the discovery payload is re-published with the extra sensors enabled.PV3/PV4 voltage, current, and power registers already exist in
growatt_neo_registers.jsonwithpublish: false. The NEO 2000 stores 32-bit power values across two adjacent registers (e.g. Ppv3 at 3013||3014 withsize: 4). The even register carries0, the odd register carries the real scaled power — the 32-bit read correctly distills the true value.
NEXA batteries (0HVR) and SPF inverters (HAQ) share the same protocol framing as NEO. Their register maps are maintained in their own JSON files. The only known difference is:
- NEXA battery naming conventions use
bat_andbatteryprefixes, parsed by_get_bat_number()which checksbatterybeforebat_to avoid false matches onbatteryCyclesandbatteryPackageQuantity(common NEXA registers). - SPF register maps are relatively small and focus on AC-side metering (grid power, load power, battery charge/discharge).
- SPF holding registers include configurable output power limits and grid-tie settings.
Every Growatt device family has a JSON register file in grobro/model/. These are loaded at
import time into GroBroRegisters Pydantic models.
{
"input_registers": {
"<sensor_name>": {
"growatt": {
"position": {
"register_no": 0,
"offset": 0,
"size": 2
},
"count": 1
},
"homeassistant": {
"type": "sensor",
"device_class": "power",
"unit_of_measurement": "W",
"state_class": "measurement"
},
"data_type": "FLOAT",
"mult": 0.1,
"name": "PV Power"
}
},
"holding_registers": { … },
"config_registers": { … }
}Three sections mirror the three modbus register spaces:
| Section | Modbus function | Purpose |
|---|---|---|
input_registers |
Read Input Registers (4) | Sensor data (read-only, e.g. power, voltage, energy) |
holding_registers |
Read Holding Registers (3) | Config / status (read-write, e.g. slots, limits) |
config_registers |
Config read 281 | Datalogger parameters (data interval, MQTT host, etc.) |
data_type |
Parser | mult / float_options |
Example |
|---|---|---|---|
FLOAT |
unsigned short × mult |
"mult": 0.1 |
raw 55 → 5.5 V |
SIGNED_FLOAT |
signed short × mult |
"mult": 0.01 |
raw -150 → -1.5 A |
INT |
unsigned short |
— | raw 1280 → 1280 W |
SIGNED_INT |
signed short |
— | raw -67 → -67 dBm |
STRING |
2 chars (hi/lo byte) | — | raw 0x4E 0x41 → "NA" |
TIME_HHMM |
hi byte = hours, lo byte = minutes | — | raw 0x17 0x2D → "23:45" |
ENUM |
mapped via INT_MAP or BITFIELD |
— | { "0": "Normal", "1": "Fault" } |
Temperature with delta correction (used by NOAH battery temperature sensors):
{
"data_type": "FLOAT",
"float_options": { "multiplier": 0.1, "delta": -273.1 }
}result = raw × 0.1 − 273.1 → raw 2971 → 24.0 °C.
A raw value of 0 produces -273.1 which is treated as "sensor offline" and replaced
with null before publishing to HA. This guard applies to any register using delta.
Energy accumulator registers (NEO eTotal, eToday, etc.) use FLOAT with mult: 0.1
to report in kWh. The inverter resets daily counters at midnight.
The first 4 characters of the serial number in the MQTT topic determine which register set to use:
| Prefix | Device family | Register file |
|---|---|---|
QMN |
NEO series | growatt_neo_registers.json |
0PVP |
NOAH series | growatt_noah_registers.json |
0HVR |
NEXA series | growatt_nexa_registers.json |
HAQ |
SPF series | growatt_spf_registers.json |
RAQ |
ShineWeLink | growatt_neo_registers.json (passthrough for LoRa NEO) |
PTQ |
NEO 1000M-X (LoRa) | growatt_neo_registers.json — extracted from ShineWeLink 0x0129 config as a separate HA device |
get_device_type_name('RAQ') returns "ShineWeLink". The PTQ prefix is now also recognized in ha/client.py — get_known_registers() returns KNOWN_NEO_REGISTERS and get_device_type_name() returns "NEO". PTQ serials are automatically extracted from ShineWeLink (RAQ) 0x0129 config messages and registered as separate inverter devices with modbus data routed to them.
The routing is currently duplicated in two places — both must be updated when adding a new prefix:
grobro/grobro/client.py—known_registersswitch in__on_message(used for modbus register routing AND config message type casting)grobro/ha/client.py—get_known_registers()andget_device_type_name()helpers (used for HA discovery)
Battery registers use a naming convention that the system parses to determine the battery index:
| Name pattern | Parsed as | Rule |
|---|---|---|
battery1Soc |
battery 1 | startswith("battery") followed by digits |
batteryPackageQuantity |
none | No digit after battery prefix |
batteryCycles |
none | No digit after battery prefix |
bat1_temp |
battery 1 | startswith("bat") followed by digits |
bat_2_ser_part_1 |
battery 2 | startswith("bat"), then _ then digits |
The check order is: battery prefix first, then bat prefix. The battery-first order
avoids false matches on batteryCycles and batteryPackageQuantity (common in NEXA
register files). Both bat1_temp and bat_2_ser_part_1 fall into the same elif name.startswith("bat"): branch — the code does not distinguish them at the prefix level.
MAX_BAT controls how many battery packs appear in HA. Default "auto" calls
_detect_bat_count(payload) which checks bat{2,3,4}_ser_part_1 values. When no
serial parts have data (e.g. sanitized test data), falls back to 4. Set to an
integer to override (e.g. MAX_BAT=1). Filtering uses bat_number > resolved_max_bat
in both publish_input_register and __publish_device_discovery.
After filtering, the four bat{N}_ser_part_{1-4} values are concatenated into a
single bat{N}_serial key in the state payload, and the individual part keys are
removed. Discovery skips _ser_part_ entities and instead publishes a single
Bat{N} Serial sensor per battery.
- Identify the register number, offset, size, and data type from a real device log
(
LOG_LEVEL=DEBUGshows unscrambled hex). - Edit the appropriate
growatt_*_registers.jsonfile, adding an entry underinput_registers,holding_registers, orconfig_registers. - Decide the data type:
FLOATwithmultfor scaled values,ENUMwithINT_MAPfor discrete states, etc. When in doubt, useINTand let users report the real meaning. - Run tests:
pytest— no test changes needed if the new register is not tested directly. For coverage, add a fixture with the new register and a parsing assertion. - Commit.
- Create
grobro/model/growatt_<name>_registers.jsonwith the full register map. - Register the JSON file in
grobro/model/growatt_registers.py:importthe JSON- Add
KNOWN_<NAME>_REGISTERS = GroBroRegisters(**data)
- Add prefix routing in
grobro/grobro/client.py(__on_messageapproach):elif device_id.startswith("<PREFIX>"): known_registers = KNOWN_<NAME>_REGISTERS
- Add HA helpers in
grobro/ha/client.py:def get_known_registers(device_id: str) -> GroBroRegisters | None: if device_id.startswith("<PREFIX>"): return KNOWN_<NAME>_REGISTERS … def get_device_type_name(device_id: str) -> str: if device_id.startswith("<PREFIX>"): return "<Name>" …
- Create test fixtures from real device logs (see §6 Testing).
- Add fixture-driven tests in the appropriate test file.
- Run full test suite and verify coverage.
- Write the parser in
grobro/grobro/parser.py. Signature:def parse_noah_*(data: bytes) -> dict. - Register in the
NOAH_DECODERSdict:0xABCD: parse_noah_abcd,
- Wire the result in
grobro/grobro/client.py__on_message. Currently onlymessage_type == 0xFE19is handled for HA discovery; add aneliffor new types that should trigger HA callbacks. - If HA integration is needed, add handling in
grobro/ha/client.py. - Add a test fixture and a parser test.
Home Assistant MQTT discovery natively supports many platform types. To add one:
- Add a register entry with
"homeassistant": { "type": "select", … }. - The
__publish_device_discoverymethod auto-generates thecmpsentry from the Pydantic model — no code change is usually needed. - Add command handling in
ha/client.py__on_messagefor the new platform'ssetaction. - Test with a command-trigger test.
GroBro uses the device-based MQTT discovery format, where a single JSON payload
contains all sensors and controls for a device under a cmps key.
homeassistant/device/<device_id>/config
{
"dev": {
"identifiers": ["QMN000ABC1D2E3FG"],
"name": "Growatt QMN000ABC1D2E3FG",
"manufacturer": "Growatt",
"model": "NEO-series (MIN-XH)",
"serial_number": "QMN000ABC1D2E3FG",
"sw_version": "1.0.0",
"hw_version": "V1.0"
},
"avty_t": "homeassistant/grobro/QMN000ABC1D2E3FG/availability",
"o": { "name": "grobro", "url": "https://github.com/robertzaage/GroBro" },
"cmps": {
"grobro_QMN000ABC1D2E3FG_sensor_Ppv": {
"platform": "sensor",
"name": "PV Power",
"unique_id": "grobro_QMN000ABC1D2E3FG_sensor_Ppv",
"device_class": "power",
"unit_of_measurement": "W",
"state_class": "measurement"
},
"grobro_QMN000ABC1D2E3FG_cmd_read_all": {
"platform": "button",
"name": "Read All Values",
"unique_id": "grobro_QMN000ABC1D2E3FG_cmd_read_all",
"command_topic": "homeassistant/button/grobro/QMN000ABC1D2E3FG/read_all/read"
}
}
}| Component | Name | Purpose |
|---|---|---|
| Button | read_all |
Read all holding registers + config registers |
| Button | restart_datalogger |
Restart the datalogger (register 32, value 1) |
| Button | sync_time |
Synchronise datalogger clock (register 31) |
| Number | config registers | Each config register (data interval, MQTT host, etc.) |
| Number | holding registers | Writable holding registers (slots, charge limits, etc.) |
| Switch | boolean registers | On/off toggles mapped to 1/0 values |
iter_command_registers(known_registers) yields a dict for every writable holding register
that has a corresponding HA platform (number, switch). This drives the dynamic generation
of command topics in HA discovery. Each yielded entry contains key, name, type,
register_no, offset, size, and HA-specific fields (min, max, step, etc.).
When FILTER_DATA_GLITCHES is enabled, publish_input_register stores the last published
value for every total_increasing sensor in _last_energy_values[device_id, key]. If a
new value is lower than the stored value, it's replaced with the stored value (suppressing
the glitch). The dict is in-memory only and resets on restart.
The __migrate_entity_discovery method publishes {"migrate_discovery": True} for legacy
entity-based discovery topics, causing Home Assistant to delete the old entities after a
version upgrade. This is called once per device on each discovery publication.
tests/
├── test_grobro_client.py # Client unit tests
├── test_ha_client.py # HA client unit tests
├── test_ha_bridge.py # Bridge wiring tests
├── test_parser.py # Parser unit tests (NOAH, config TLV)
├── test_builder.py # Scramble/CRC roundtrip
└── model/
├── test_commands.py # Binary fixture roundtrip + parsing
└── test_models.py # Pydantic model tests
Raw scrambled packets are stored in tests/model/data/*.bin. Every .bin file must have
a corresponding test. The test_all_binary_files_exist fixture enforces this.
Fixture creation workflow:
- Capture a real packet: set
LOG_LEVEL=DEBUGor copy a line from the log:Received: c/33/RAQ0E8H042 00 00 00 07 02 41 01 04 … - Extract the unscrambled hex (everything after the topic).
- Scramble it:
scramble(bytes.fromhex("<hex>")). - Anonymize serials: replace real serials with same-length test serials
(
RAQ0E8H042→RAQ0TEST01,PTQ0N6Q0E3→PTQ0TEST01, etc.). - Save the scrambled anonymized bytes as
tests/model/data/<DeviceType><Description>.bin.
An existing file can be used as a reference for the correct format. The test
test_no_original_serial verifies that no real serials leaked into the repository.
Important: The LOG.debug("Received: …") output at client.py:241 is already
unscrambled. Do not call unscramble() again on hex copied from the log. The .bin
files store the scrambled payload as it arrives from MQTT.
Pattern A — callback assertion:
def test_my_message(self, client):
data = (Path(DATA_DIR) / "MyFixture.bin").read_bytes()
msg = _msg("c/33/RAQ0TEST01", data)
client._client.on_message(None, None, msg)
client.on_input_register.assert_called_once()Pattern B — parsed value verification:
def test_my_parsed_value(self, client):
data = (Path(DATA_DIR) / "MyFixture.bin").read_bytes()
payload = _load_payload("MyFixture.bin")
assert payload["Ppv"] == 85.9Pattern C — scrambled roundtrip:
def test_unscramble(self, name):
raw = (Path(DATA_DIR) / name).read_bytes()
u = unscramble(raw)
assert len(u) > 0- Project threshold: 85 % (
fail_under = 85inpyproject.toml) - Patch threshold: 80 %
- Run:
pytest --cov=grobro --cov-report=xml - Lint:
ruff check grobro/ tests/(F rule set)
pyproject.toml defines two pytest markers:
| Marker | Purpose |
|---|---|
slow |
Marks slow tests (-m "not slow" to skip) |
integration |
Marks tests requiring external resources |
Neither marker is currently applied to any test — all tests are fast and self-contained.
The following package directories exist but contain __pycache__ only (no Python source):
grobro/core/grobro/devices/grobro/models/grobro/mqtt/
These are vestigial and can be removed.
Config read state (_config_read_queues, _config_read_inflight, _config_read_timers)
is shared class-state on ha.Client. Tests that exercise config read logic must clear
these structures on teardown to avoid cross-test interference:
def teardown_method(self):
self.client._config_read_queues.clear()
self.client._config_read_inflight.clear()
self.client._config_read_timers.clear()| Workflow | File | Trigger | Action |
|---|---|---|---|
| Tests | tests.yml |
Push/PR to main |
Matrix across Python 3.11–3.13, ruff lint, pytest with coverage, upload to Codecov |
| Docker build | docker-build.yml |
CI pass on main + release publish |
Multi-arch build (linux/amd64, linux/arm64, linux/arm/v7), push to GHCR |
| GHCR cleanup | ghcr-cleanup.yml |
Daily at 20:40 UTC | Remove old untagged images |
Docker tags:
:dev— latestmainbranch build:latest— latest tagged release:<version>— specific version tag
| Tool | Path | Purpose |
|---|---|---|
grocli |
grobro/tools/grocli.py |
Send commands (charge limit, output limit, slots, power set) to devices via MQTT |
reg_msg_decoder |
grobro/tools/reg_msg_decoder.py |
Decode captured binary dumps — descrambles, identifies message type, parses payload |
GroBro follows semantic versioning (MAJOR.MINOR.PATCH). The current version is
maintained only in the git tag and the release. There is no version file in the repository.
All variables use the MQTTConfig.from_env() mechanism. SOURCE_* is the broker where
Growatt devices publish. TARGET_* is the broker Home Assistant listens on. FORWARD_*
is the Growatt Cloud relay broker.
| Variable | Default | Effect |
|---|---|---|
SOURCE_MQTT_HOST |
"localhost" |
Source (Growatt device) MQTT broker hostname |
SOURCE_MQTT_PORT |
1883 |
Source MQTT broker port |
SOURCE_MQTT_TLS |
False |
Enable TLS for source broker |
SOURCE_MQTT_USER |
None |
Username for source broker |
SOURCE_MQTT_PASS |
None |
Password for source broker |
TARGET_MQTT_HOST |
= SOURCE_MQTT_HOST |
Target (Home Assistant) MQTT broker hostname |
TARGET_MQTT_PORT |
= SOURCE_MQTT_PORT |
Target MQTT broker port |
TARGET_MQTT_TLS |
= SOURCE_MQTT_TLS |
Enable TLS for target broker |
TARGET_MQTT_USER |
= SOURCE_MQTT_USER |
Username for target broker |
TARGET_MQTT_PASS |
= SOURCE_MQTT_PASS |
Password for target broker |
FORWARD_MQTT_HOST |
"mqtt.growatt.com" |
Forward (Growatt Cloud) broker hostname |
FORWARD_MQTT_PORT |
7006 |
Forward broker port |
FORWARD_MQTT_TLS |
False |
Enable TLS for forward broker |
FORWARD_MQTT_USER |
None |
Username for forward broker |
FORWARD_MQTT_PASS |
None |
Password for forward broker |
Note: TARGET defaults chain to SOURCE values (not hardcoded). If you set SOURCE_MQTT_HOST
but not TARGET_MQTT_HOST, TARGET inherits from SOURCE.
| Variable | Default | Effect |
|---|---|---|
LOG_LEVEL |
"ERROR" |
Logging level (DEBUG, INFO, WARNING, ERROR). DEBUG shows full packet hex. |
HA_BASE_TOPIC |
"homeassistant" |
Base MQTT topic for HA auto-discovery and state |
MQTT_CLIENT_SUFFIX |
"" (empty) |
Optional suffix for MQTT client IDs (allows running parallel instances) |
DEVICE_TIMEOUT |
0 |
Seconds after which a device is considered offline (0 = disabled) |
AVAILABILITY_SENSOR |
False |
When True, exposes a dedicated online binary sensor; when False, marks all entities unavailable |
PUBLISH_SENSORS_RETAINED |
False |
Publish sensor states with MQTT retain flag |
MAX_SLOTS |
1 |
Maximum number of battery time slots for scheduling |
MAX_BAT |
"auto" |
Battery pack count in HA. "auto" detects from bat{N}_ser_part_1 presence; set to a number to override. |
FILTER_DATA_GLITCHES |
False |
When True, prevents decreases on total_increasing sensors (energy counters) after device reconnect |
GROWATT_CLOUD |
"false" |
Forward messages to Growatt Cloud. "true" forwards all; comma-separated serial list forwards only matching devices |
GROWATT_CLOUD_CONFIG_FILTER |
"false" |
When True, blocks config messages from being forwarded to the cloud |
DUMP_MESSAGES |
False |
Save every raw MQTT payload as <DUMP_DIR>/<topic_path>/<timestamp_ms>.bin |
DUMP_DIR |
"/dump" |
Directory for dumped message files (in HA add-on: "/share/GroBro/dump") |
DUMP_MESSAGES saves raw (still-scrambled) MQTT payloads to topic-based subdirectories
with millisecond-precision timestamps:
<DUMP_DIR>/<topic_parts>/<epoch_millis>.bin
For example, a message on topic c/33/QMN000ABC1D2E3FG is saved as:
/dump/c/33/QMN000ABC1D2E3FG/1700123456789.bin
Because the payload is saved before unscrambling, you must call unscramble() on the
file contents before parsing. The reg_msg_decoder tool handles this automatically.
The SOURCE and TARGET env vars can point to entirely separate MQTT brokers. For
development, running a local Mosquitto as TARGET and using a remote TLS broker as
SOURCE is a common setup:
SOURCE_HOST=my-growatt-broker.example.com \
SOURCE_PORT=7006 \
SOURCE_USE_TLS=true \
TARGET_HOST=localhost \
TARGET_PORT=1883 \
python -m grobro.ha_bridge