From e2b30687438404f496c71f2fc73201ea78a506ae Mon Sep 17 00:00:00 2001 From: mle Date: Sun, 5 May 2024 21:27:09 +0200 Subject: [PATCH] Add BMS real time monitoring settings to ET --- goodwe/et.py | 93 ++++++++++++++++++++++++++++++++++++---------- goodwe/inverter.py | 2 + goodwe/modbus.py | 4 +- tests/test_et.py | 17 +++++---- 4 files changed, 87 insertions(+), 29 deletions(-) diff --git a/goodwe/et.py b/goodwe/et.py index 96a138c..35843dc 100644 --- a/goodwe/et.py +++ b/goodwe/et.py @@ -3,10 +3,11 @@ import logging from typing import Tuple -from .exceptions import RequestRejectedException +from .exceptions import RequestFailedException, RequestRejectedException from .inverter import Inverter from .inverter import OperationMode from .inverter import SensorKind as Kind +from .modbus import ILLEGAL_DATA_ADDRESS from .model import is_2_battery, is_4_mppt, is_745_platform, is_single_phase from .protocol import ProtocolCommand from .sensor import * @@ -331,7 +332,7 @@ class ET(Inverter): # Modbus registers of inverter settings, offsets are modbus register addresses __all_settings: Tuple[Sensor, ...] = ( Integer("comm_address", 45127, "Communication Address", ""), - + Integer("modbus_baud_rate", 45132, "Modbus Baud rate", ""), Timestamp("time", 45200, "Inverter time"), Integer("sensitivity_check", 45246, "Sensitivity Check Mode", "", Kind.AC), @@ -371,6 +372,51 @@ class ET(Inverter): ByteH("eco_mode_3_switch", 47526, "Eco Mode Group 3 Switch"), EcoModeV1("eco_mode_4", 47527, "Eco Mode Group 4"), ByteH("eco_mode_4_switch", 47530, "Eco Mode Group 4 Switch"), + + # Direct BMS communication for EMS Control + Integer("bms_version", 47900, "BMS Version"), + Integer("bms_bat_modules", 47901, "BMS Battery Modules"), + # Real time read from BMS + Voltage("bms_bat_charge_v_max", 47902, "BMS Battery Charge Voltage (max)", Kind.BMS), + Current("bms_bat_charge_i_max", 47903, "BMS Battery Charge Current (max)", Kind.BMS), + Voltage("bms_bat_discharge_v_min", 47904, "BMS min. Battery Discharge Voltage (min)", Kind.BMS), + Current("bms_bat_discharge_i_max", 47905, "BMS max. Battery Discharge Current (max)", Kind.BMS), + Voltage("bms_bat_voltage", 47906, "BMS Battery Voltage", Kind.BMS), + Current("bms_bat_current", 47907, "BMS Battery Current", Kind.BMS), + # + Integer("bms_bat_soc", 47908, "BMS Battery State of Charge", "%", Kind.BMS), + Integer("bms_bat_soh", 47909, "BMS Battery State of Health", "%", Kind.BMS), + Temp("bms_bat_temperature", 47910, "BMS Battery Temperature", Kind.BMS), + Long("bms_bat_warning-code", 47911, "BMS Battery Warning Code"), + # Reserved + Long("bms_bat_alarm-code", 47913, "BMS Battery Alarm Code"), + Integer("bms_status", 47915, "BMS Status"), + Integer("bms_comm_loss_disable", 47916, "BMS Communication Loss Disable"), + # RW settings of BMS voltage rate + Integer("bms_battery_string_rate_v", 47917, "BMS Battery String Rate Voltage"), + + # Direct BMS communication for EMS Control + Integer("bms2_version", 47918, "BMS2 Version"), + Integer("bms2_bat_modules", 47919, "BMS2 Battery Modules"), + # Real time read from BMS + Voltage("bms2_bat_charge_v_max", 47920, "BMS2 Battery Charge Voltage (max)", Kind.BMS), + Current("bms2_bat_charge_i_max", 47921, "BMS2 Battery Charge Current (max)", Kind.BMS), + Voltage("bms2_bat_discharge_v_min", 47922, "BMS2 min. Battery Discharge Voltage (min)", Kind.BMS), + Current("bms2_bat_discharge_i_max", 47923, "BMS2 max. Battery Discharge Current (max)", Kind.BMS), + Voltage("bms2_bat_voltage", 47924, "BMS2 Battery Voltage", Kind.BMS), + Current("bms2_bat_current", 47925, "BMS2 Battery Current", Kind.BMS), + # + Integer("bms2_bat_soc", 47926, "BMS2 Battery State of Charge", "%", Kind.BMS), + Integer("bms2_bat_soh", 47927, "BMS2 Battery State of Health", "%", Kind.BMS), + Temp("bms2_bat_temperature", 47928, "BMS2 Battery Temperature", Kind.BMS), + Long("bms2_bat_warning-code", 47929, "BMS2 Battery Warning Code"), + # Reserved + Long("bms2_bat_alarm-code", 47931, "BMS2 Battery Alarm Code"), + Integer("bms2_status", 47933, "BMS2 Status"), + Integer("bms2_comm_loss_disable", 47934, "BMS2 Communication Loss Disable"), + # RW settings of BMS voltage rate + Integer("bms2_battery_string_rate_v", 47935, "BMS2 Battery String Rate Voltage"), + ) # Settings added in ARM firmware 19 @@ -389,6 +435,7 @@ class ET(Inverter): Integer("load_control_mode", 47595, "Load Control Mode", "", Kind.AC), Integer("load_control_switch", 47596, "Load Control Switch", "", Kind.AC), Integer("load_control_soc", 47597, "Load Control SoC", "", Kind.AC), + Integer("hardware_feed_power", 47599, "Hardware Feed Power"), Integer("fast_charging_power", 47603, "Fast Charging Power", "%", Kind.BAT), ) @@ -447,19 +494,19 @@ def _not_extended_meter(s: Sensor) -> bool: async def read_device_info(self): response = await self._read_from_socket(self._READ_DEVICE_VERSION_INFO) response = response.response_data() - # Modbus registers from offset (35000) + # Modbus registers from 35000 - 35032 self.modbus_version = read_unsigned_int(response, 0) self.rated_power = read_unsigned_int(response, 2) self.ac_output_type = read_unsigned_int(response, 4) # 0: 1-phase, 1: 3-phase (4 wire), 2: 3-phase (3 wire) - self.serial_number = self._decode(response[6:22]) - self.model_name = self._decode(response[22:32]) - self.dsp1_version = read_unsigned_int(response, 32) - self.dsp2_version = read_unsigned_int(response, 34) - self.dsp_svn_version = read_unsigned_int(response, 36) - self.arm_version = read_unsigned_int(response, 38) - self.arm_svn_version = read_unsigned_int(response, 40) - self.firmware = self._decode(response[42:54]) - self.arm_firmware = self._decode(response[54:66]) + self.serial_number = self._decode(response[6:22]) # 35003 - 350010 + self.model_name = self._decode(response[22:32]) # 35011 - 35015 + self.dsp1_version = read_unsigned_int(response, 32) # 35016 + self.dsp2_version = read_unsigned_int(response, 34) # 35017 + self.dsp_svn_version = read_unsigned_int(response, 36) # 35018 + self.arm_version = read_unsigned_int(response, 38) # 35019 + self.arm_svn_version = read_unsigned_int(response, 40) # 35020 + self.firmware = self._decode(response[42:54]) # 35021 - 35027 + self.arm_firmware = self._decode(response[54:66]) # 35027 - 35032 if not is_4_mppt(self) and self.rated_power < 15000: # This inverter does not have 4 MPPTs or PV strings @@ -485,7 +532,7 @@ async def read_device_info(self): await self._read_from_socket(self._read_command(47547, 6)) self._settings.update({s.id_: s for s in self.__settings_arm_fw_19}) except RequestRejectedException as ex: - if ex.message == 'ILLEGAL DATA ADDRESS': + if ex.message == ILLEGAL_DATA_ADDRESS: logger.debug("Cannot read EcoModeV2 settings, using to EcoModeV1.") self._has_eco_mode_v2 = False @@ -494,7 +541,7 @@ async def read_device_info(self): await self._read_from_socket(self._read_command(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': + if ex.message == ILLEGAL_DATA_ADDRESS: logger.debug("Cannot read PeakShaving setting, disabling it.") self._has_peak_shaving = False @@ -508,7 +555,7 @@ async def read_runtime_data(self) -> Dict[str, Any]: response = await self._read_from_socket(self._READ_BATTERY_INFO) data.update(self._map_response(response, self._sensors_battery)) except RequestRejectedException as ex: - if ex.message == 'ILLEGAL DATA ADDRESS': + if ex.message == ILLEGAL_DATA_ADDRESS: logger.warning("Cannot read battery values, disabling further attempts.") self._has_battery = False else: @@ -519,7 +566,7 @@ async def read_runtime_data(self) -> Dict[str, Any]: data.update( self._map_response(response, self._sensors_battery2)) except RequestRejectedException as ex: - if ex.message == 'ILLEGAL DATA ADDRESS': + if ex.message == ILLEGAL_DATA_ADDRESS: logger.warning("Cannot read battery 2 values, disabling further attempts.") self._has_battery2 = False else: @@ -530,7 +577,7 @@ async def read_runtime_data(self) -> Dict[str, Any]: response = await self._read_from_socket(self._READ_METER_DATA_EXTENDED) data.update(self._map_response(response, self._sensors_meter)) except RequestRejectedException as ex: - if ex.message == 'ILLEGAL DATA ADDRESS': + if ex.message == ILLEGAL_DATA_ADDRESS: logger.warning("Cannot read extended meter values, disabling further attempts.") self._has_meter_extended = False self._sensors_meter = tuple(filter(self._not_extended_meter, self._sensors_meter)) @@ -548,7 +595,7 @@ async def read_runtime_data(self) -> Dict[str, Any]: response = await self._read_from_socket(self._READ_MPPT_DATA) data.update(self._map_response(response, self._sensors_mppt)) except RequestRejectedException as ex: - if ex.message == 'ILLEGAL DATA ADDRESS': + if ex.message == ILLEGAL_DATA_ADDRESS: logger.warning("Cannot read MPPT values, disabling further attempts.") self._has_mppt = False else: @@ -560,7 +607,13 @@ 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) + try: + return await self._read_setting(setting) + except RequestRejectedException as ex: + if ex.message == ILLEGAL_DATA_ADDRESS: + logger.debug("Unsupported setting %s", setting.id_) + self._settings.pop(setting_id, None) + return None async def _read_setting(self, setting: Sensor) -> Any: count = (setting.size_ + (setting.size_ % 2)) // 2 @@ -592,7 +645,7 @@ async def read_settings_data(self) -> Dict[str, Any]: try: value = await self.read_setting(setting.id_) data[setting.id_] = value - except ValueError: + except (ValueError, RequestFailedException): logger.exception("Error reading setting %s.", setting.id_) data[setting.id_] = None return data diff --git a/goodwe/inverter.py b/goodwe/inverter.py index a5cd660..65fe395 100644 --- a/goodwe/inverter.py +++ b/goodwe/inverter.py @@ -22,6 +22,7 @@ class SensorKind(Enum): UPS - inverter ups/eps/backup output (e.g. ac voltage of backup/off-grid connected output) BAT - battery (e.g. dc voltage of connected battery pack) GRID - power grid/smart meter (e.g. active power exported to grid) + BMS - BMS direct data (e.g. dc voltage of) """ PV = 1 @@ -29,6 +30,7 @@ class SensorKind(Enum): UPS = 3 BAT = 4 GRID = 5 + BMS = 6 @dataclass diff --git a/goodwe/modbus.py b/goodwe/modbus.py index 0f7ea2a..0a3cc01 100644 --- a/goodwe/modbus.py +++ b/goodwe/modbus.py @@ -9,9 +9,11 @@ MODBUS_WRITE_CMD: int = 0x6 MODBUS_WRITE_MULTI_CMD: int = 0x10 +ILLEGAL_DATA_ADDRESS = 'ILLEGAL DATA ADDRESS' + FAILURE_CODES = { 1: "ILLEGAL FUNCTION", - 2: "ILLEGAL DATA ADDRESS", + 2: ILLEGAL_DATA_ADDRESS, 3: "ILLEGAL DATA VALUE", 4: "SLAVE DEVICE FAILURE", 5: "ACKNOWLEDGE", diff --git a/tests/test_et.py b/tests/test_et.py index f6a9823..b2eeeb5 100644 --- a/tests/test_et.py +++ b/tests/test_et.py @@ -6,6 +6,7 @@ from goodwe.et import ET from goodwe.exceptions import RequestRejectedException, RequestFailedException from goodwe.inverter import OperationMode +from goodwe.modbus import ILLEGAL_DATA_ADDRESS from goodwe.protocol import ModbusRtuReadCommand, ProtocolCommand, ProtocolResponse @@ -26,8 +27,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') + 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): @@ -56,8 +57,8 @@ 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(ModbusRtuReadCommand(self.comm_addr, 47547, 6), 'ILLEGAL DATA ADDRESS') - self.mock_response(ModbusRtuReadCommand(self.comm_addr, 47589, 6), 'ILLEGAL DATA ADDRESS') + self.mock_response(ModbusRtuReadCommand(self.comm_addr, 47547, 6), ILLEGAL_DATA_ADDRESS) + self.mock_response(ModbusRtuReadCommand(self.comm_addr, 47589, 6), ILLEGAL_DATA_ADDRESS) self.mock_response(ModbusRtuReadCommand(self.comm_addr, 47515, 4), 'eco_mode_v1.hex') def test_GW10K_ET_device_info(self): @@ -237,7 +238,7 @@ def test_GW10K_ET_runtime_data(self): self.assertFalse(self.sensor_map, f"Some sensors were not tested {self.sensor_map}") def test_GW10K_ET_setting(self): - self.assertEqual(32, len(self.settings())) + self.assertEqual(65, len(self.settings())) settings = {s.id_: s for s in self.settings()} self.assertEqual('Timestamp', type(settings.get("time")).__name__) self.assertEqual('EcoModeV1', type(settings.get("eco_mode_1")).__name__) @@ -311,7 +312,7 @@ 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(ModbusRtuReadCommand(self.comm_addr, 47547, 6), 'eco_mode_v2.hex') - self.mock_response(ModbusRtuReadCommand(self.comm_addr, 47589, 6), 'ILLEGAL DATA ADDRESS') + self.mock_response(ModbusRtuReadCommand(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): @@ -329,7 +330,7 @@ def test_GW10K_ET_fw819_device_info(self): self.assertEqual('02041-19-S00', self.arm_firmware) def test_GW10K_ET_settings_fw819(self): - self.assertEqual(38, len(self.settings())) + self.assertEqual(72, len(self.settings())) settings = {s.id_: s for s in self.settings()} self.assertEqual('EcoModeV2', type(settings.get("eco_mode_1")).__name__) self.assertEqual(None, settings.get("peak_shaving_mode")) @@ -369,7 +370,7 @@ def test_GW10K_ET_fw1023_device_info(self): self.assertEqual('02041-23-S00', self.arm_firmware) def test_GW10K_ET_setting_fw1023(self): - self.assertEqual(46, len(self.settings())) + self.assertEqual(80, len(self.settings())) settings = {s.id_: s for s in self.settings()} self.assertEqual('PeakShavingMode', type(settings.get("peak_shaving_mode")).__name__)