diff --git a/goodwe/et.py b/goodwe/et.py index b38e815..573a6b6 100644 --- a/goodwe/et.py +++ b/goodwe/et.py @@ -252,7 +252,8 @@ class ET(Inverter): Apparent4("meter_apparent_power_total", 36041, "Meter Apparent Power Total", Kind.GRID), Integer("meter_type", 36043, "Meter Type", "", Kind.GRID), # (0: Single phase, 1: 3P3W, 2: 3P4W, 3: HomeKit) Integer("meter_sw_version", 36044, "Meter Software Version", "", Kind.GRID), - # Sensors added in some ARM fw update, read when flag _has_meter_extended is on + + # Sensors added in some ARM fw update (or platform 745/753), read when flag _has_meter_extended is on Power4S("meter2_active_power", 36045, "Meter 2 Active Power", Kind.GRID), Float("meter2_e_total_exp", 36047, 1000, "Meter 2 Total Energy (export)", "kWh", Kind.GRID), Float("meter2_e_total_imp", 36049, 1000, "Meter 2 Total Energy (import)", "kWh", Kind.GRID), @@ -263,6 +264,15 @@ class ET(Inverter): Current("meter_current1", 36055, "Meter L1 Current", Kind.GRID), Current("meter_current2", 36056, "Meter L2 Current", Kind.GRID), Current("meter_current3", 36057, "Meter L3 Current", Kind.GRID), + + Energy8("meter_e_total_exp1", 36092, "Meter Total Energy (export) L1", Kind.GRID), + Energy8("meter_e_total_exp2", 36096, "Meter Total Energy (export) L2", Kind.GRID), + Energy8("meter_e_total_exp3", 36100, "Meter Total Energy (export) L3", Kind.GRID), + Energy8("meter_e_total_exp", 36104, "Meter Total Energy (export)", Kind.GRID), + Energy8("meter_e_total_imp1", 36108, "Meter Total Energy (import) L1", Kind.GRID), + Energy8("meter_e_total_imp2", 36112, "Meter Total Energy (import) L2", Kind.GRID), + Energy8("meter_e_total_imp3", 36116, "Meter Total Energy (import) L3", Kind.GRID), + Energy8("meter_e_total_imp", 36120, "Meter Total Energy (import)", Kind.GRID), ) # Inverter's MPPT data @@ -414,6 +424,7 @@ def __init__(self, host: str, comm_addr: int = 0, timeout: int = 1, retries: int self._READ_RUNNING_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x891c, 0x007d) self._READ_METER_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x8ca0, 0x2d) self._READ_METER_DATA_EXTENDED: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x8ca0, 0x3a) + self._READ_METER_DATA_EXTENDED2: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x8ca0, 0x7d) self._READ_BATTERY_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x9088, 0x0018) self._READ_BATTERY2_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x9858, 0x0016) self._READ_MPPT_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x89e5, 0x3d) @@ -422,6 +433,7 @@ def __init__(self, host: str, comm_addr: int = 0, timeout: int = 1, retries: int self._has_battery: bool = True self._has_battery2: bool = False self._has_meter_extended: bool = False + self._has_meter_extended2: bool = False self._has_mppt: bool = False self._sensors = self.__all_sensors self._sensors_battery = self.__all_sensors_battery @@ -440,6 +452,11 @@ def _not_extended_meter(s: Sensor) -> bool: """Filter to exclude extended meter sensors""" return s.offset < 36045 + @staticmethod + def _not_extended_meter2(s: Sensor) -> bool: + """Filter to exclude extended meter sensors""" + return s.offset < 36058 + async def read_device_info(self): response = await self._read_from_socket(self._READ_DEVICE_VERSION_INFO) response = response.response_data() @@ -470,9 +487,10 @@ async def read_device_info(self): if is_2_battery(self) or self.rated_power >= 25000: self._has_battery2 = True - if self.rated_power >= 15000: + if is_745_platform(self) or self.rated_power >= 15000: self._has_mppt = True self._has_meter_extended = True + self._has_meter_extended2 = True else: self._sensors_meter = tuple(filter(self._not_extended_meter, self._sensors_meter)) @@ -527,7 +545,21 @@ async def read_runtime_data(self) -> Dict[str, Any]: else: raise ex - if self._has_meter_extended: + if self._has_meter_extended2: + try: + response = await self._read_from_socket(self._READ_METER_DATA_EXTENDED2) + data.update(self._map_response(response, self._sensors_meter)) + except RequestRejectedException as ex: + if ex.message == 'ILLEGAL DATA ADDRESS': + logger.info("Extended meter values not supported, disabling further attempts.") + self._has_meter_extended2 = False + self._sensors_meter = tuple(filter(self._not_extended_meter2, self._sensors_meter)) + response = await self._read_from_socket(self._READ_METER_DATA_EXTENDED) + data.update( + self._map_response(response, self._sensors_meter)) + else: + raise ex + elif self._has_meter_extended: try: response = await self._read_from_socket(self._READ_METER_DATA_EXTENDED) data.update(self._map_response(response, self._sensors_meter)) diff --git a/goodwe/model.py b/goodwe/model.py index d08cf56..95a2ab9 100644 --- a/goodwe/model.py +++ b/goodwe/model.py @@ -48,3 +48,7 @@ def is_2_battery(inverter: Inverter) -> bool: def is_745_platform(inverter: Inverter) -> bool: return any(model in inverter.serial_number for model in PLATFORM_745_LV_MODELS) or any( model in inverter.serial_number for model in PLATFORM_745_HV_MODELS) + + +def is_753_platform(inverter: Inverter) -> bool: + return any(model in inverter.serial_number for model in PLATFORM_753_MODELS) diff --git a/goodwe/sensor.py b/goodwe/sensor.py index 15f8625..d5c16e4 100644 --- a/goodwe/sensor.py +++ b/goodwe/sensor.py @@ -197,6 +197,17 @@ def read_value(self, data: ProtocolResponse): return float(value) / 10 if value is not None else None +class Energy8(Sensor): + """Sensor representing energy [kWh] value encoded in 8 bytes""" + + def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): + super().__init__(id_, offset, name, 8, "kWh", kind) + + def read_value(self, data: ProtocolResponse): + value = read_bytes8(data) + return float(value) / 100 if value is not None else None + + class Apparent(Sensor): """Sensor representing apparent power [VA] value encoded in 2 bytes""" @@ -816,6 +827,14 @@ def read_bytes4_signed(buffer: ProtocolResponse, offset: int = None) -> int: return int.from_bytes(buffer.read(4), byteorder="big", signed=True) +def read_bytes8(buffer: ProtocolResponse, offset: int = None, undef: int = None) -> int: + """Retrieve 8 byte (unsigned int) value from buffer""" + if offset is not None: + buffer.seek(offset) + value = int.from_bytes(buffer.read(8), byteorder="big", signed=False) + return undef if value == 0xffffffffffffffff else value + + def read_decimal2(buffer: ProtocolResponse, scale: int, offset: int = None) -> float: """Retrieve 2 byte (signed float) value from buffer""" if offset is not None: diff --git a/tests/test_et.py b/tests/test_et.py index e6c8985..cf41f8e 100644 --- a/tests/test_et.py +++ b/tests/test_et.py @@ -14,7 +14,7 @@ class EtMock(TestCase, ET): def __init__(self, methodName='runTest'): TestCase.__init__(self, methodName) ET.__init__(self, "localhost") - self.sensor_map = {s.id_: s.unit for s in self.sensors()} + self.sensor_map = {s.id_: s for s in self.sensors()} self._mock_responses = {} self._list_of_requests = [] @@ -40,10 +40,11 @@ async def _read_from_socket(self, command: ProtocolCommand) -> ProtocolResponse: self._list_of_requests.append(command.request) return ProtocolResponse(bytes.fromhex("aa55f700010203040506070809"), command) - def assertSensor(self, sensor, expected_value, expected_unit, data): - self.assertEqual(expected_value, data.get(sensor)) - self.assertEqual(expected_unit, self.sensor_map.get(sensor)) - self.sensor_map.pop(sensor) + def assertSensor(self, sensor_name, expected_value, expected_unit, data): + self.assertEqual(expected_value, data.get(sensor_name)) + sensor = self.sensor_map.get(sensor_name) + self.assertEqual(expected_unit, sensor.unit) + self.sensor_map.pop(sensor_name) @classmethod def setUpClass(cls): @@ -80,13 +81,15 @@ def test_GW10K_ET_device_info(self): def test_GW10K_ET_runtime_data(self): # Reset sensors self.loop.run_until_complete(self.read_device_info()) - self.sensor_map = {s.id_: s.unit for s in self.sensors()} + self.sensor_map = {s.id_: s for s in self.sensors()} data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(145, len(data)) + self.assertEqual(36015, self.sensor_map.get("meter_e_total_exp").offset) + # for sensor in self.sensors(): - # print(f"self.assertSensor('{sensor.id_}', {data[sensor.id_]}, '{self.sensor_map.get(sensor.id_)}', data)") + # print(f"self.assertSensor('{sensor.id_}', {data[sensor.id_]}, '{self.sensor_map.get(sensor.id_).unit}', data)") self.assertSensor('timestamp', datetime.strptime('2021-08-22 11:11:12', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 332.6, 'V', data) @@ -488,7 +491,7 @@ def test_GEH10_1U_10_device_info(self): def test_GEH10_1U_10_runtime_data(self): # Reset sensors self.loop.run_until_complete(self.read_device_info()) - self.sensor_map = {s.id_: s.unit for s in self.sensors()} + self.sensor_map = {s.id_: s for s in self.sensors()} data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(125, len(data)) @@ -652,6 +655,7 @@ def __init__(self, methodName='runTest'): EtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW25K-ET_device_info.hex') self.mock_response(self._READ_RUNNING_DATA, 'GW25K-ET_running_data.hex') + self.mock_response(self._READ_METER_DATA_EXTENDED2, 'ILLEGAL DATA ADDRESS') self.mock_response(self._READ_METER_DATA_EXTENDED, 'GW25K-ET_meter_data.hex') self.mock_response(self._READ_BATTERY_INFO, 'GW25K-ET_battery_info.hex') self.mock_response(self._READ_MPPT_DATA, 'GW25K-ET_mppt_data.hex') @@ -674,11 +678,14 @@ def test_GW25K_ET_device_info(self): def test_GW25K_ET_runtime_data(self): # Reset sensors self.loop.run_until_complete(self.read_device_info()) - self.sensor_map = {s.id_: s.unit for s in self.sensors()} data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(237, len(data)) + self.sensor_map = {s.id_: s for s in self.sensors()} + + # self.assertEqual(36104, self.sensor_map.get("meter_e_total_exp").offset) + self.assertSensor('timestamp', datetime.strptime('2023-12-03 14:07:07', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 737.9, 'V', data) self.assertSensor('ipv1', 1.4, 'A', data) @@ -928,6 +935,7 @@ def __init__(self, methodName='runTest'): EtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW29K9-ET_device_info.hex') self.mock_response(self._READ_RUNNING_DATA, 'GW29K9-ET_running_data.hex') + self.mock_response(self._READ_METER_DATA_EXTENDED2, 'ILLEGAL DATA ADDRESS') self.mock_response(self._READ_METER_DATA_EXTENDED, 'GW29K9-ET_meter_data.hex') self.mock_response(self._READ_BATTERY_INFO, 'GW29K9-ET_battery_info.hex') self.mock_response(self._READ_BATTERY2_INFO, 'GW29K9-ET_battery2_info.hex') @@ -951,11 +959,12 @@ def test_GW29K9_ET_device_info(self): def test_GW29K9_ET_runtime_data(self): # Reset sensors self.loop.run_until_complete(self.read_device_info()) - self.sensor_map = {s.id_: s.unit for s in self.sensors()} data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(211, len(data)) + self.sensor_map = {s.id_: s for s in self.sensors()} + self.assertSensor('timestamp', datetime.strptime('2024-01-17 14:49:14', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 682.9, 'V', data) self.assertSensor('ipv1', 1.5, 'A', data) @@ -1098,32 +1107,6 @@ def test_GW29K9_ET_runtime_data(self): self.assertSensor('meter_current1', 4.6, 'A', data) self.assertSensor('meter_current2', 6.0, 'A', data) self.assertSensor('meter_current3', 13.6, 'A', data) - self.assertSensor('battery_bms', None, '', data) - self.assertSensor('battery_index', None, '', data) - self.assertSensor('battery_status', None, '', data) - self.assertSensor('battery_temperature', None, 'C', data) - self.assertSensor('battery_charge_limit', None, 'A', data) - self.assertSensor('battery_discharge_limit', None, 'A', data) - self.assertSensor('battery_error_l', None, '', data) - self.assertSensor('battery_soc', None, '%', data) - self.assertSensor('battery_soh', None, '%', data) - self.assertSensor('battery_modules', None, '', data) - self.assertSensor('battery_warning_l', None, '', data) - self.assertSensor('battery_protocol', None, '', data) - self.assertSensor('battery_error_h', None, '', data) - self.assertSensor('battery_error', None, '', data) - self.assertSensor('battery_warning_h', None, '', data) - self.assertSensor('battery_warning', None, '', data) - self.assertSensor('battery_sw_version', None, '', data) - self.assertSensor('battery_hw_version', None, '', data) - self.assertSensor('battery_max_cell_temp_id', None, '', data) - self.assertSensor('battery_min_cell_temp_id', None, '', data) - self.assertSensor('battery_max_cell_voltage_id', None, '', data) - self.assertSensor('battery_min_cell_voltage_id', None, '', data) - self.assertSensor('battery_max_cell_temp', None, 'C', data) - self.assertSensor('battery_min_cell_temp', None, 'C', data) - self.assertSensor('battery_max_cell_voltage', None, 'V', data) - self.assertSensor('battery_min_cell_voltage', None, 'V', data) self.assertSensor('battery2_status', 0, '', data) self.assertSensor('battery2_temperature', 0.0, 'C', data) self.assertSensor('battery2_charge_limit', 0, 'A', data) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 193b7be..06ca924 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -155,6 +155,16 @@ def test_energy4(self): data = MockResponse("ffffffff") self.assertIsNone(testee.read(data)) + def test_energy8(self): + testee = Energy8("", 0, "", None) + + data = MockResponse("0000000000015b41") + self.assertEqual(888.97, testee.read(data)) + data = MockResponse("0000000000038E6C") + self.assertEqual(2330.68, testee.read(data)) + data = MockResponse("ffffffffffffffff") + self.assertIsNone(testee.read(data)) + def test_timestamp(self): testee = Timestamp("", 0, "", None)