From 925f37d51d7d0370d1220d55e7f95b3cf06e16f1 Mon Sep 17 00:00:00 2001 From: mle Date: Sat, 17 Feb 2024 15:00:16 +0100 Subject: [PATCH] Improve detection of EcoMode type Do not rely on firmware number (is limited to ETU type), probe the settings directly if it is present or not. --- goodwe/es.py | 73 ++++++++++++++++----------------- goodwe/et.py | 46 ++++++++++++--------- goodwe/protocol.py | 21 +++++++++- tests/sample/et/eco_mode_v1.hex | 1 + tests/sample/et/eco_mode_v2.hex | 1 + tests/test_et.py | 11 ++++- 6 files changed, 94 insertions(+), 59 deletions(-) create mode 100644 tests/sample/et/eco_mode_v1.hex create mode 100644 tests/sample/et/eco_mode_v2.hex diff --git a/goodwe/es.py b/goodwe/es.py index 0406993..3ace672 100644 --- a/goodwe/es.py +++ b/goodwe/es.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Tuple, cast +from typing import Tuple from .exceptions import InverterError from .inverter import Inverter @@ -220,17 +220,20 @@ async def read_setting(self, setting_id: str) -> Any: setting: Sensor | None = self._settings.get(setting_id) if not setting: raise ValueError(f'Unknown setting "{setting_id}"') - count = (setting.size_ + (setting.size_ % 2)) // 2 - if self._is_modbus_setting(setting): - response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, count)) - return setting.read_value(response) - else: - response = await self._read_from_socket(Aa55ReadCommand(setting.offset, count)) - return setting.read_value(response) + return await self._read_setting(setting) else: all_settings = await self.read_settings_data() return all_settings.get(setting_id) + async def _read_setting(self, setting: Sensor) -> Any: + count = (setting.size_ + (setting.size_ % 2)) // 2 + if self._is_modbus_setting(setting): + response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, count)) + return setting.read_value(response) + else: + response = await self._read_from_socket(Aa55ReadCommand(setting.offset, count)) + return setting.read_value(response) + async def write_setting(self, setting_id: str, value: Any): if setting_id == 'time': await self._read_from_socket( @@ -240,27 +243,30 @@ async def write_setting(self, setting_id: str, value: Any): setting: Sensor | None = self._settings.get(setting_id) if not setting: raise ValueError(f'Unknown setting "{setting_id}"') - if setting.size_ == 1: - # modbus can address/store only 16 bit values, read the other 8 bytes - if self._is_modbus_setting(setting): - response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, 1)) - raw_value = setting.encode_value(value, response.response_data()[0:2]) - else: - response = await self._read_from_socket(Aa55ReadCommand(setting.offset, 1)) - raw_value = setting.encode_value(value, response.response_data()[2:4]) + await self._write_setting(setting, value) + + async def _write_setting(self, setting: Sensor, value: Any): + if setting.size_ == 1: + # modbus can address/store only 16 bit values, read the other 8 bytes + if self._is_modbus_setting(setting): + response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, 1)) + raw_value = setting.encode_value(value, response.response_data()[0:2]) else: - raw_value = setting.encode_value(value) - if len(raw_value) <= 2: - value = int.from_bytes(raw_value, byteorder="big", signed=True) - if self._is_modbus_setting(setting): - await self._read_from_socket(ModbusWriteCommand(self.comm_addr, setting.offset, value)) - else: - await self._read_from_socket(Aa55WriteCommand(setting.offset, value)) + response = await self._read_from_socket(Aa55ReadCommand(setting.offset, 1)) + raw_value = setting.encode_value(value, response.response_data()[2:4]) + else: + raw_value = setting.encode_value(value) + if len(raw_value) <= 2: + value = int.from_bytes(raw_value, byteorder="big", signed=True) + if self._is_modbus_setting(setting): + await self._read_from_socket(ModbusWriteCommand(self.comm_addr, setting.offset, value)) + else: + await self._read_from_socket(Aa55WriteCommand(setting.offset, value)) + else: + if self._is_modbus_setting(setting): + await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, setting.offset, raw_value)) else: - if self._is_modbus_setting(setting): - await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, setting.offset, raw_value)) - else: - await self._read_from_socket(Aa55WriteMultiCommand(setting.offset, raw_value)) + await self._read_from_socket(Aa55WriteMultiCommand(setting.offset, raw_value)) async def read_settings_data(self) -> Dict[str, Any]: response = await self._read_from_socket(self._READ_DEVICE_SETTINGS_DATA) @@ -313,7 +319,8 @@ async def set_operation_mode(self, operation_mode: OperationMode, eco_mode_power raise ValueError() if eco_mode_soc < 0 or eco_mode_soc > 100: raise ValueError() - eco_mode: EcoMode = self._convert_eco_mode(EcoModeV2("", 0, "")) + eco_mode: EcoMode | Sensor = self._settings.get('eco_mode_1') + await self._read_setting(eco_mode) if operation_mode == OperationMode.ECO_CHARGE: await self.write_setting('eco_mode_1', eco_mode.encode_charge(eco_mode_power, eco_mode_soc)) else: @@ -427,13 +434,5 @@ async def _set_store_energy_mode(self, mode: int) -> None: async def _set_work_mode(self, mode: int) -> None: await self._read_from_socket(Aa55ProtocolCommand("035901" + "{:02x}".format(mode), "03D9")) - def _convert_eco_mode(self, sensor: Sensor) -> Sensor | EcoMode: - if EcoModeV1 == type(sensor) and self._supports_eco_mode_v2(): - return cast(EcoModeV1, sensor).as_eco_mode_v2() - elif EcoModeV2 == type(sensor) and not self._supports_eco_mode_v2(): - return cast(EcoModeV2, sensor).as_eco_mode_v1() - else: - return sensor - def _is_modbus_setting(self, sensor: Sensor) -> bool: - return EcoModeV2 == type(sensor) or sensor.offset > 30000 + return sensor.offset > 30000 diff --git a/goodwe/et.py b/goodwe/et.py index 70e09be..3dae067 100644 --- a/goodwe/et.py +++ b/goodwe/et.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Tuple, cast +from typing import Tuple from .exceptions import RequestRejectedException from .inverter import Inverter @@ -411,6 +411,8 @@ def __init__(self, host: str, comm_addr: int = 0, timeout: int = 1, retries: int 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) + self._has_eco_mode_v2: bool = True + self._has_peak_shaving: bool = True self._has_battery: bool = True self._has_battery2: bool = False self._has_meter_extended: bool = False @@ -422,12 +424,6 @@ def __init__(self, host: str, comm_addr: int = 0, timeout: int = 1, retries: int self._sensors_mppt = self.__all_sensors_mppt self._settings: dict[str, Sensor] = {s.id_: s for s in self.__all_settings} - def _supports_eco_mode_v2(self) -> bool: - return self.arm_version >= 19 or 'ETU' not in self.serial_number - - def _supports_peak_shaving(self) -> bool: - return self.arm_version >= 22 or 'ETU' not in self.serial_number - @staticmethod def _single_phase_only(s: Sensor) -> bool: """Filter to exclude phase2/3 sensors on single phase inverters""" @@ -474,10 +470,23 @@ async def read_device_info(self): else: self._sensors_meter = tuple(filter(self._not_extended_meter, self._sensors_meter)) - if self.arm_version >= 19 or 'ETU' not in self.serial_number: + # Check and add EcoModeV2 settings added in (ETU fw 19) + try: + await self._read_from_socket(ModbusReadCommand(self.comm_addr, 47547, 6)) self._settings.update({s.id_: s for s in self.__settings_arm_fw_19}) - if self.arm_version >= 22 or 'ETU' not in self.serial_number: + except RequestRejectedException as ex: + if ex.message == 'ILLEGAL DATA ADDRESS': + logger.debug("Cannot read EcoModeV2 settings, using to EcoModeV1.") + self._has_eco_mode_v2 = False + + # Check and add Peak Shaving settings added in (ETU fw 22) + try: + await self._read_from_socket(ModbusReadCommand(self.comm_addr, 47589, 6)) self._settings.update({s.id_: s for s in self.__settings_arm_fw_22}) + except RequestRejectedException as ex: + if ex.message == 'ILLEGAL DATA ADDRESS': + logger.debug("Cannot read PeakShaving setting, disabling it.") + self._has_peak_shaving = False async def read_runtime_data(self) -> Dict[str, Any]: response = await self._read_from_socket(self._READ_RUNNING_DATA) @@ -541,6 +550,9 @@ async def read_setting(self, setting_id: str) -> Any: setting = self._settings.get(setting_id) if not setting: raise ValueError(f'Unknown setting "{setting_id}"') + return await self._read_setting(setting) + + async def _read_setting(self, setting: Sensor) -> Any: count = (setting.size_ + (setting.size_ % 2)) // 2 response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, count)) return setting.read_value(response) @@ -549,6 +561,9 @@ async def write_setting(self, setting_id: str, value: Any): setting = self._settings.get(setting_id) if not setting: raise ValueError(f'Unknown setting "{setting_id}"') + await self._write_setting(setting, value) + + async def _write_setting(self, setting: Sensor, value: Any): if setting.size_ == 1: # modbus can address/store only 16 bit values, read the other 8 bytes response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, 1)) @@ -581,7 +596,7 @@ async def set_grid_export_limit(self, export_limit: int) -> None: async def get_operation_modes(self, include_emulated: bool) -> Tuple[OperationMode, ...]: result = [e for e in OperationMode] - if not self._supports_peak_shaving(): + if not self._has_peak_shaving: result.remove(OperationMode.PEAK_SHAVING) if not include_emulated: result.remove(OperationMode.ECO_CHARGE) @@ -626,7 +641,8 @@ async def set_operation_mode(self, operation_mode: OperationMode, eco_mode_power raise ValueError() if eco_mode_soc < 0 or eco_mode_soc > 100: raise ValueError() - eco_mode: EcoMode = self._convert_eco_mode(EcoModeV2("", 0, "")) + eco_mode: EcoMode | Sensor = self._settings.get('eco_mode_1') + await self._read_setting(eco_mode) if operation_mode == OperationMode.ECO_CHARGE: await self.write_setting('eco_mode_1', eco_mode.encode_charge(eco_mode_power, eco_mode_soc)) else: @@ -663,11 +679,3 @@ async def _clear_battery_mode_param(self) -> None: async def _set_offline(self, mode: bool) -> None: value = bytes.fromhex('00070000') if mode else bytes.fromhex('00010000') await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, 0xb997, value)) - - def _convert_eco_mode(self, sensor: Sensor) -> Sensor | EcoMode: - if EcoModeV1 == type(sensor) and self._supports_eco_mode_v2(): - return cast(EcoModeV1, sensor).as_eco_mode_v2() - elif EcoModeV2 == type(sensor) and not self._supports_eco_mode_v2(): - return cast(EcoModeV2, sensor).as_eco_mode_v1() - else: - return sensor diff --git a/goodwe/protocol.py b/goodwe/protocol.py index c5950c3..fe37f72 100644 --- a/goodwe/protocol.py +++ b/goodwe/protocol.py @@ -85,7 +85,7 @@ def _retry_mechanism(self) -> None: class ProtocolResponse: """Definition of response to protocol command""" - def __init__(self, raw_data: bytes, command: ProtocolCommand): + def __init__(self, raw_data: bytes, command: Optional[ProtocolCommand]): self.raw_data: bytes = raw_data self.command: ProtocolCommand = command self._bytes: io.BytesIO = io.BytesIO(self.response_data()) @@ -116,6 +116,15 @@ def __init__(self, request: bytes, validator: Callable[[bytes], bool]): self.request: bytes = request self.validator: Callable[[bytes], bool] = validator + def __eq__(self, other): + if not isinstance(other, ProtocolCommand): + # don't attempt to compare against unrelated types + return NotImplemented + return self.request == other.request + + def __hash__(self): + return hash(self.request) + def __repr__(self): return self.request.hex() @@ -276,6 +285,7 @@ def __init__(self, request: bytes, cmd: int, offset: int, value: int): lambda x: validate_modbus_response(x, cmd, offset, value), ) self.first_address: int = offset + self.value = value def trim_response(self, raw_response: bytes): """Trim raw response from header and checksum data""" @@ -296,6 +306,12 @@ def __init__(self, comm_addr: int, offset: int, count: int): create_modbus_request(comm_addr, MODBUS_READ_CMD, offset, count), MODBUS_READ_CMD, offset, count) + def __repr__(self): + if self.value > 1: + return f'READ {self.value} registers from {self.first_address} ({self.request.hex()})' + else: + return f'READ register {self.first_address} ({self.request.hex()})' + class ModbusWriteCommand(ModbusProtocolCommand): """ @@ -307,6 +323,9 @@ def __init__(self, comm_addr: int, register: int, value: int): create_modbus_request(comm_addr, MODBUS_WRITE_CMD, register, value), MODBUS_WRITE_CMD, register, value) + def __repr__(self): + return f'WRITE {self.value} to register {self.first_address} ({self.request.hex()})' + class ModbusWriteMultiCommand(ModbusProtocolCommand): """ diff --git a/tests/sample/et/eco_mode_v1.hex b/tests/sample/et/eco_mode_v1.hex new file mode 100644 index 0000000..f48a5c1 --- /dev/null +++ b/tests/sample/et/eco_mode_v1.hex @@ -0,0 +1 @@ +aa55f703080000173b0014007f6f5a \ No newline at end of file diff --git a/tests/sample/et/eco_mode_v2.hex b/tests/sample/et/eco_mode_v2.hex new file mode 100644 index 0000000..73ee36c --- /dev/null +++ b/tests/sample/et/eco_mode_v2.hex @@ -0,0 +1 @@ +aa55f7030c0000173b007f0014006400008d53 \ No newline at end of file diff --git a/tests/test_et.py b/tests/test_et.py index 2554bd4..3149acc 100644 --- a/tests/test_et.py +++ b/tests/test_et.py @@ -4,9 +4,9 @@ from unittest import TestCase from goodwe.et import ET -from goodwe.exceptions import RequestFailedException +from goodwe.exceptions import RequestRejectedException, RequestFailedException from goodwe.inverter import OperationMode -from goodwe.protocol import ProtocolCommand, ProtocolResponse +from goodwe.protocol import ModbusReadCommand, ProtocolCommand, ProtocolResponse class EtMock(TestCase, ET): @@ -26,6 +26,8 @@ async def _read_from_socket(self, command: ProtocolCommand) -> ProtocolResponse: root_dir = os.path.dirname(os.path.abspath(__file__)) filename = self._mock_responses.get(command) if filename is not None: + if 'ILLEGAL DATA ADDRESS' == filename: + raise RequestRejectedException('ILLEGAL DATA ADDRESS') with open(root_dir + '/sample/et/' + filename, 'r') as f: response = bytes.fromhex(f.read()) if not command.validator(response): @@ -54,6 +56,9 @@ def __init__(self, methodName='runTest'): self.mock_response(self._READ_RUNNING_DATA, 'GW10K-ET_running_data.hex') self.mock_response(self._READ_METER_DATA, 'GW10K-ET_meter_data.hex') self.mock_response(self._READ_BATTERY_INFO, 'GW10K-ET_battery_info.hex') + self.mock_response(ModbusReadCommand(self.comm_addr, 47547, 6), 'ILLEGAL DATA ADDRESS') + self.mock_response(ModbusReadCommand(self.comm_addr, 47589, 6), 'ILLEGAL DATA ADDRESS') + self.mock_response(ModbusReadCommand(self.comm_addr, 47515, 4), 'eco_mode_v1.hex') def test_GW10K_ET_device_info(self): self.loop.run_until_complete(self.read_device_info()) @@ -305,6 +310,8 @@ class GW10K_ET_fw819_Test(EtMock): def __init__(self, methodName='runTest'): EtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW10K-ET_device_info_fw819.hex') + self.mock_response(ModbusReadCommand(self.comm_addr, 47547, 6), 'eco_mode_v2.hex') + self.mock_response(ModbusReadCommand(self.comm_addr, 47589, 6), 'ILLEGAL DATA ADDRESS') asyncio.get_event_loop().run_until_complete(self.read_device_info()) def test_GW10K_ET_fw819_device_info(self):