From 8e26c336013e7e79597c9877fe8269b4b93915ea Mon Sep 17 00:00:00 2001 From: Patrick Date: Sun, 9 Aug 2026 19:54:10 +0200 Subject: [PATCH 1/3] Fix handling of holding register dump --- grobro/grobro/client.py | 8 +++++--- grobro/model/modbus_message.py | 9 +++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/grobro/grobro/client.py b/grobro/grobro/client.py index 65f015e..c9bc180 100644 --- a/grobro/grobro/client.py +++ b/grobro/grobro/client.py @@ -411,14 +411,16 @@ def __on_message(self, client, userdata, msg: MQTTMessage): LOG.info("Modbus message from unknown device type: %s", device_id) return - if ( - modbus_message.function - == GrowattModbusFunction.READ_SINGLE_REGISTER + if modbus_message.function in ( + GrowattModbusFunction.READ_SINGLE_REGISTER, + GrowattModbusFunction.READ_HOLDING_REGISTER, ): state = HomeAssistantHoldingRegisterInput(device_id=modbus_device_id) for name, register in known_registers.holding_registers.items(): data_raw = modbus_message.get_data(register.growatt.position) + if data_raw is None: + continue value = register.growatt.data.parse(data_raw) if value is None: continue diff --git a/grobro/model/modbus_message.py b/grobro/model/modbus_message.py index c23f093..474e17d 100644 --- a/grobro/model/modbus_message.py +++ b/grobro/model/modbus_message.py @@ -116,7 +116,7 @@ class GrowattModbusMessage(BaseModel): - B - 1 byte modbus device address (seems to be constant 1 in mqtt) - B - 1 byte function - 30s - 30 byte zero-padded device id - - optional GrowattModbusMetadata - only present when function == READ_INPUT_REGISTER + - optional GrowattModbusMetadata - present for functions READ_INPUT_REGISTER and READ_HOLDING_REGISTER - N register blocks """ @@ -163,12 +163,17 @@ def parse_grobro(buffer) -> Optional["GrowattModbusMessage"]: offset = 38 metadata = None - if function == GrowattModbusFunction.READ_INPUT_REGISTER: + if function in ( + GrowattModbusFunction.READ_INPUT_REGISTER, + GrowattModbusFunction.READ_HOLDING_REGISTER, + ): metadata = GrowattMetadata.parse_grobro(buffer[offset:]) offset += metadata.size() while len(buffer) > offset + 6: block = GrowattModbusBlock.parse_grobro(buffer[offset:]) + if block is None: + return None register_blocks.append(block) offset += block.size() From daaa23f1c2e74cdd5c399e5cef700e8261fa9a06 Mon Sep 17 00:00:00 2001 From: Patrick Date: Sun, 9 Aug 2026 20:26:31 +0200 Subject: [PATCH 2/3] Remove Noah specific handling of message type 0x0103 --- grobro/grobro/client.py | 2 +- grobro/grobro/parser.py | 20 -------------------- tests/model/test_commands.py | 10 +--------- 3 files changed, 2 insertions(+), 30 deletions(-) diff --git a/grobro/grobro/client.py b/grobro/grobro/client.py index c9bc180..c21dfd2 100644 --- a/grobro/grobro/client.py +++ b/grobro/grobro/client.py @@ -371,7 +371,7 @@ def __on_message(self, client, userdata, msg: MQTTMessage): self._client.publish(topic, smart_meter["data"], retain=PUBLISH_SENSORS_RETAINED) return - # NOAH/NEXA-specific message types (FE19 config, 0103 holding regs, etc.) + # NOAH/NEXA-specific message types (FE19 config, etc.) noah_msg = parser.parse_noah_message(unscrambled) if noah_msg and noah_msg.get("message_type") == 0xFE19: if device_id.startswith("0PVP") or device_id.startswith("0HVR"): diff --git a/grobro/grobro/parser.py b/grobro/grobro/parser.py index d3e63d8..5e15636 100644 --- a/grobro/grobro/parser.py +++ b/grobro/grobro/parser.py @@ -167,25 +167,6 @@ def parse_config_ack(data: bytes): # The 2-byte type/subtype field starts at payload offset 14. -def parse_noah_0103(data: bytes) -> dict: - """ - NOAH type 0x0103 — Holding register dump. - Payload: 14 zero bytes + 16B device serial (offset 14) + register data. - """ - payload = data[24:] - device_id = payload[14:30].rstrip(b"\x00").decode("ascii", errors="replace") - reg_data = payload[30:] - registers = [] - for i in range(0, len(reg_data) - 1, 2): - registers.append(struct.unpack_from(">H", reg_data, i)[0]) - return { - "message_type": 0x0103, - "device_id": device_id, - "registers": registers, - "register_count": len(registers), - } - - def parse_noah_0110(data: bytes) -> dict: """ NOAH type 0x0110 — Preset-multiple register response/ack. @@ -315,7 +296,6 @@ def parse_noah_6f64(data: bytes) -> dict: NOAH_DECODERS = { - 0x0103: parse_noah_0103, 0x0110: parse_noah_0110, 0x0125: parse_noah_0125, 0xFE18: parse_noah_fe18, diff --git a/tests/model/test_commands.py b/tests/model/test_commands.py index c5dd205..105bf67 100644 --- a/tests/model/test_commands.py +++ b/tests/model/test_commands.py @@ -128,6 +128,7 @@ def test_config_messages(file_name, exp_register, exp_value_contains): ("NeoSetOutputPowerLimit.bin", GrowattModbusFunctionSingle), ("NeoReadInputRegisters.bin", GrowattModbusMessage), ("NeoReadSingleRegister_3.bin", GrowattModbusMessage), + ("NoahType0103_HoldingRegs.bin", GrowattModbusMessage), ] MODBUS_PARSE_FILES = [ @@ -226,15 +227,6 @@ def test_noah_preset_multiple_charge_limit(): assert result["message_type"] in (0x0110, 0x0111) -def test_noah_type0103_holding_registers(): - data = (DATA_DIR / "NoahType0103_HoldingRegs.bin").read_bytes() - unscrambled = parser.unscramble(data) - result = parser.parse_noah_0103(unscrambled) - assert result["message_type"] == 0x0103 - assert result["device_id"] == NOAH_TEST_DEVICE_ID - assert result["register_count"] > 0 - - def test_noah_type0110_response(): data = (DATA_DIR / "NoahType0110_PresetMResp.bin").read_bytes() unscrambled = parser.unscramble(data) From 4d95bff2a0d7e54d83c24846fa2ab88f70515368 Mon Sep 17 00:00:00 2001 From: Patrick Date: Sun, 9 Aug 2026 20:42:40 +0200 Subject: [PATCH 3/3] Fix test --- tests/model/test_commands.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/model/test_commands.py b/tests/model/test_commands.py index 105bf67..37c4f11 100644 --- a/tests/model/test_commands.py +++ b/tests/model/test_commands.py @@ -4,6 +4,7 @@ from grobro.grobro import parser from grobro.model.modbus_message import ( + GrowattModbusFunction, GrowattModbusMessage, ) from grobro.model.modbus_function import GrowattModbusFunctionSingle @@ -128,7 +129,6 @@ def test_config_messages(file_name, exp_register, exp_value_contains): ("NeoSetOutputPowerLimit.bin", GrowattModbusFunctionSingle), ("NeoReadInputRegisters.bin", GrowattModbusMessage), ("NeoReadSingleRegister_3.bin", GrowattModbusMessage), - ("NoahType0103_HoldingRegs.bin", GrowattModbusMessage), ] MODBUS_PARSE_FILES = [ @@ -227,6 +227,18 @@ def test_noah_preset_multiple_charge_limit(): assert result["message_type"] in (0x0110, 0x0111) +def test_noah_type0103_holding_registers(): + data = (DATA_DIR / "NoahType0103_HoldingRegs.bin").read_bytes() + unscrambled = parser.unscramble(data) + result = GrowattModbusMessage.parse_grobro(unscrambled) + assert result is not None + assert result.device_id == NOAH_TEST_DEVICE_ID + assert result.function == GrowattModbusFunction.READ_HOLDING_REGISTER + assert result.metadata is not None + assert result.metadata.device_sn == NOAH_TEST_DEVICE_ID + assert len(result.register_blocks) > 0 + + def test_noah_type0110_response(): data = (DATA_DIR / "NoahType0110_PresetMResp.bin").read_bytes() unscrambled = parser.unscramble(data)