goodwe 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {goodwe-0.4.0/goodwe.egg-info → goodwe-0.4.2}/PKG-INFO +1 -1
  2. goodwe-0.4.2/VERSION +1 -0
  3. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe/dt.py +40 -13
  4. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe/es.py +7 -6
  5. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe/et.py +103 -38
  6. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe/exceptions.py +14 -0
  7. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe/inverter.py +13 -9
  8. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe/modbus.py +10 -9
  9. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe/protocol.py +115 -49
  10. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe/sensor.py +28 -4
  11. {goodwe-0.4.0 → goodwe-0.4.2/goodwe.egg-info}/PKG-INFO +1 -1
  12. {goodwe-0.4.0 → goodwe-0.4.2}/tests/test_dt.py +1 -1
  13. {goodwe-0.4.0 → goodwe-0.4.2}/tests/test_es.py +15 -1
  14. {goodwe-0.4.0 → goodwe-0.4.2}/tests/test_et.py +158 -25
  15. {goodwe-0.4.0 → goodwe-0.4.2}/tests/test_modbus.py +12 -4
  16. {goodwe-0.4.0 → goodwe-0.4.2}/tests/test_protocol.py +1 -1
  17. {goodwe-0.4.0 → goodwe-0.4.2}/tests/test_sensor.py +11 -0
  18. goodwe-0.4.0/VERSION +0 -1
  19. {goodwe-0.4.0 → goodwe-0.4.2}/LICENSE +0 -0
  20. {goodwe-0.4.0 → goodwe-0.4.2}/README.md +0 -0
  21. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe/__init__.py +0 -0
  22. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe/const.py +0 -0
  23. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe/model.py +0 -0
  24. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe.egg-info/SOURCES.txt +0 -0
  25. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe.egg-info/dependency_links.txt +0 -0
  26. {goodwe-0.4.0 → goodwe-0.4.2}/goodwe.egg-info/top_level.txt +0 -0
  27. {goodwe-0.4.0 → goodwe-0.4.2}/pyproject.toml +0 -0
  28. {goodwe-0.4.0 → goodwe-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: goodwe
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Read data from GoodWe inverter via local network
5
5
  Home-page: https://github.com/marcelblijleven/goodwe
6
6
  Author: Martin Letenay, Marcel Blijleven
goodwe-0.4.2/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.4.2
@@ -1,15 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  from typing import Tuple
4
5
 
5
- from .exceptions import InverterError
6
+ from .exceptions import InverterError, RequestRejectedException
6
7
  from .inverter import Inverter
7
8
  from .inverter import OperationMode
8
9
  from .inverter import SensorKind as Kind
10
+ from .modbus import ILLEGAL_DATA_ADDRESS
9
11
  from .model import is_3_mppt, is_single_phase
10
12
  from .protocol import ProtocolCommand
11
13
  from .sensor import *
12
14
 
15
+ logger = logging.getLogger(__name__)
16
+
13
17
 
14
18
  class DT(Inverter):
15
19
  """Class representing inverter of DT/MS/D-NS/XS or GE's GEP(PSB/PSC) families"""
@@ -123,10 +127,7 @@ class DT(Inverter):
123
127
  )
124
128
 
125
129
  def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
126
- super().__init__(host, port, comm_addr, timeout, retries)
127
- if not self.comm_addr:
128
- # Set the default inverter address
129
- self.comm_addr = 0x7f
130
+ super().__init__(host, port, comm_addr if comm_addr else 0x7f, timeout, retries)
130
131
  self._READ_DEVICE_VERSION_INFO: ProtocolCommand = self._read_command(0x7531, 0x0028)
131
132
  self._READ_DEVICE_RUNNING_DATA: ProtocolCommand = self._read_command(0x7594, 0x0049)
132
133
  self._sensors = self.__all_sensors
@@ -177,17 +178,43 @@ class DT(Inverter):
177
178
 
178
179
  async def read_setting(self, setting_id: str) -> Any:
179
180
  setting = self._settings.get(setting_id)
180
- if not setting:
181
- raise ValueError(f'Unknown setting "{setting_id}"')
182
- count = (setting.size_ + (setting.size_ % 2)) // 2
183
- response = await self._read_from_socket(self._read_command(setting.offset, count))
184
- return setting.read_value(response)
181
+ if setting:
182
+ return await self._read_setting(setting)
183
+ else:
184
+ if setting_id.startswith("modbus"):
185
+ response = await self._read_from_socket(self._read_command(int(setting_id[7:]), 1))
186
+ return int.from_bytes(response.read(2), byteorder="big", signed=True)
187
+ else:
188
+ raise ValueError(f'Unknown setting "{setting_id}"')
189
+
190
+ async def _read_setting(self, setting: Sensor) -> Any:
191
+ try:
192
+ count = (setting.size_ + (setting.size_ % 2)) // 2
193
+ response = await self._read_from_socket(self._read_command(setting.offset, count))
194
+ return setting.read_value(response)
195
+ except RequestRejectedException as ex:
196
+ if ex.message == ILLEGAL_DATA_ADDRESS:
197
+ logger.debug("Unsupported setting %s", setting.id_)
198
+ self._settings.pop(setting.id_, None)
199
+ return None
185
200
 
186
201
  async def write_setting(self, setting_id: str, value: Any):
187
202
  setting = self._settings.get(setting_id)
188
- if not setting:
189
- raise ValueError(f'Unknown setting "{setting_id}"')
190
- raw_value = setting.encode_value(value)
203
+ if setting:
204
+ await self._write_setting(setting, value)
205
+ else:
206
+ if setting_id.startswith("modbus"):
207
+ await self._read_from_socket(self._write_command(int(setting_id[7:]), int(value)))
208
+ else:
209
+ raise ValueError(f'Unknown setting "{setting_id}"')
210
+
211
+ async def _write_setting(self, setting: Sensor, value: Any):
212
+ if setting.size_ == 1:
213
+ # modbus can address/store only 16 bit values, read the other 8 bytes
214
+ response = await self._read_from_socket(self._read_command(setting.offset, 1))
215
+ raw_value = setting.encode_value(value, response.response_data()[0:2])
216
+ else:
217
+ raw_value = setting.encode_value(value)
191
218
  if len(raw_value) <= 2:
192
219
  value = int.from_bytes(raw_value, byteorder="big", signed=True)
193
220
  await self._read_from_socket(self._write_command(setting.offset, value))
@@ -168,10 +168,7 @@ class ES(Inverter):
168
168
  )
169
169
 
170
170
  def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
171
- super().__init__(host, port, comm_addr, timeout, retries)
172
- if not self.comm_addr:
173
- # Set the default inverter address
174
- self.comm_addr = 0xf7
171
+ super().__init__(host, port, comm_addr if comm_addr else 0xf7, timeout, retries)
175
172
  self._settings: dict[str, Sensor] = {s.id_: s for s in self.__all_settings}
176
173
 
177
174
  def _supports_eco_mode_v2(self) -> bool:
@@ -220,6 +217,9 @@ class ES(Inverter):
220
217
  if not setting:
221
218
  raise ValueError(f'Unknown setting "{setting_id}"')
222
219
  return await self._read_setting(setting)
220
+ elif setting_id.startswith("modbus"):
221
+ response = await self._read_from_socket(self._read_command(int(setting_id[7:]), 1))
222
+ return int.from_bytes(response.read(2), byteorder="big", signed=True)
223
223
  else:
224
224
  all_settings = await self.read_settings_data()
225
225
  return all_settings.get(setting_id)
@@ -238,6 +238,8 @@ class ES(Inverter):
238
238
  await self._read_from_socket(
239
239
  Aa55ProtocolCommand("030206" + Timestamp("time", 0, "").encode_value(value).hex(), "0382")
240
240
  )
241
+ elif setting_id.startswith("modbus"):
242
+ await self._read_from_socket(self._write_command(int(setting_id[7:]), int(value)))
241
243
  else:
242
244
  setting: Sensor | None = self._settings.get(setting_id)
243
245
  if not setting:
@@ -249,10 +251,9 @@ class ES(Inverter):
249
251
  # modbus can address/store only 16 bit values, read the other 8 bytes
250
252
  if self._is_modbus_setting(setting):
251
253
  response = await self._read_from_socket(self._read_command(setting.offset, 1))
252
- raw_value = setting.encode_value(value, response.response_data()[0:2])
253
254
  else:
254
255
  response = await self._read_from_socket(Aa55ReadCommand(setting.offset, 1))
255
- raw_value = setting.encode_value(value, response.response_data()[2:4])
256
+ raw_value = setting.encode_value(value, response.response_data()[0:2])
256
257
  else:
257
258
  raw_value = setting.encode_value(value)
258
259
  if len(raw_value) <= 2:
@@ -3,10 +3,11 @@ from __future__ import annotations
3
3
  import logging
4
4
  from typing import Tuple
5
5
 
6
- from .exceptions import RequestRejectedException
6
+ from .exceptions import RequestFailedException, RequestRejectedException
7
7
  from .inverter import Inverter
8
8
  from .inverter import OperationMode
9
9
  from .inverter import SensorKind as Kind
10
+ from .modbus import ILLEGAL_DATA_ADDRESS
10
11
  from .model import is_2_battery, is_4_mppt, is_745_platform, is_single_phase
11
12
  from .protocol import ProtocolCommand
12
13
  from .sensor import *
@@ -331,7 +332,7 @@ class ET(Inverter):
331
332
  # Modbus registers of inverter settings, offsets are modbus register addresses
332
333
  __all_settings: Tuple[Sensor, ...] = (
333
334
  Integer("comm_address", 45127, "Communication Address", ""),
334
-
335
+ Integer("modbus_baud_rate", 45132, "Modbus Baud rate", ""),
335
336
  Timestamp("time", 45200, "Inverter time"),
336
337
 
337
338
  Integer("sensitivity_check", 45246, "Sensitivity Check Mode", "", Kind.AC),
@@ -371,6 +372,51 @@ class ET(Inverter):
371
372
  ByteH("eco_mode_3_switch", 47526, "Eco Mode Group 3 Switch"),
372
373
  EcoModeV1("eco_mode_4", 47527, "Eco Mode Group 4"),
373
374
  ByteH("eco_mode_4_switch", 47530, "Eco Mode Group 4 Switch"),
375
+
376
+ # Direct BMS communication for EMS Control
377
+ Integer("bms_version", 47900, "BMS Version"),
378
+ Integer("bms_bat_modules", 47901, "BMS Battery Modules"),
379
+ # Real time read from BMS
380
+ Voltage("bms_bat_charge_v_max", 47902, "BMS Battery Charge Voltage (max)", Kind.BMS),
381
+ Current("bms_bat_charge_i_max", 47903, "BMS Battery Charge Current (max)", Kind.BMS),
382
+ Voltage("bms_bat_discharge_v_min", 47904, "BMS min. Battery Discharge Voltage (min)", Kind.BMS),
383
+ Current("bms_bat_discharge_i_max", 47905, "BMS max. Battery Discharge Current (max)", Kind.BMS),
384
+ Voltage("bms_bat_voltage", 47906, "BMS Battery Voltage", Kind.BMS),
385
+ Current("bms_bat_current", 47907, "BMS Battery Current", Kind.BMS),
386
+ #
387
+ Integer("bms_bat_soc", 47908, "BMS Battery State of Charge", "%", Kind.BMS),
388
+ Integer("bms_bat_soh", 47909, "BMS Battery State of Health", "%", Kind.BMS),
389
+ Temp("bms_bat_temperature", 47910, "BMS Battery Temperature", Kind.BMS),
390
+ Long("bms_bat_warning-code", 47911, "BMS Battery Warning Code"),
391
+ # Reserved
392
+ Long("bms_bat_alarm-code", 47913, "BMS Battery Alarm Code"),
393
+ Integer("bms_status", 47915, "BMS Status"),
394
+ Integer("bms_comm_loss_disable", 47916, "BMS Communication Loss Disable"),
395
+ # RW settings of BMS voltage rate
396
+ Integer("bms_battery_string_rate_v", 47917, "BMS Battery String Rate Voltage"),
397
+
398
+ # Direct BMS communication for EMS Control
399
+ Integer("bms2_version", 47918, "BMS2 Version"),
400
+ Integer("bms2_bat_modules", 47919, "BMS2 Battery Modules"),
401
+ # Real time read from BMS
402
+ Voltage("bms2_bat_charge_v_max", 47920, "BMS2 Battery Charge Voltage (max)", Kind.BMS),
403
+ Current("bms2_bat_charge_i_max", 47921, "BMS2 Battery Charge Current (max)", Kind.BMS),
404
+ Voltage("bms2_bat_discharge_v_min", 47922, "BMS2 min. Battery Discharge Voltage (min)", Kind.BMS),
405
+ Current("bms2_bat_discharge_i_max", 47923, "BMS2 max. Battery Discharge Current (max)", Kind.BMS),
406
+ Voltage("bms2_bat_voltage", 47924, "BMS2 Battery Voltage", Kind.BMS),
407
+ Current("bms2_bat_current", 47925, "BMS2 Battery Current", Kind.BMS),
408
+ #
409
+ Integer("bms2_bat_soc", 47926, "BMS2 Battery State of Charge", "%", Kind.BMS),
410
+ Integer("bms2_bat_soh", 47927, "BMS2 Battery State of Health", "%", Kind.BMS),
411
+ Temp("bms2_bat_temperature", 47928, "BMS2 Battery Temperature", Kind.BMS),
412
+ Long("bms2_bat_warning-code", 47929, "BMS2 Battery Warning Code"),
413
+ # Reserved
414
+ Long("bms2_bat_alarm-code", 47931, "BMS2 Battery Alarm Code"),
415
+ Integer("bms2_status", 47933, "BMS2 Status"),
416
+ Integer("bms2_comm_loss_disable", 47934, "BMS2 Communication Loss Disable"),
417
+ # RW settings of BMS voltage rate
418
+ Integer("bms2_battery_string_rate_v", 47935, "BMS2 Battery String Rate Voltage"),
419
+
374
420
  )
375
421
 
376
422
  # Settings added in ARM firmware 19
@@ -389,6 +435,7 @@ class ET(Inverter):
389
435
  Integer("load_control_mode", 47595, "Load Control Mode", "", Kind.AC),
390
436
  Integer("load_control_switch", 47596, "Load Control Switch", "", Kind.AC),
391
437
  Integer("load_control_soc", 47597, "Load Control SoC", "", Kind.AC),
438
+ Integer("hardware_feed_power", 47599, "Hardware Feed Power"),
392
439
 
393
440
  Integer("fast_charging_power", 47603, "Fast Charging Power", "%", Kind.BAT),
394
441
  )
@@ -410,10 +457,7 @@ class ET(Inverter):
410
457
  )
411
458
 
412
459
  def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
413
- super().__init__(host, port, comm_addr, timeout, retries)
414
- if not self.comm_addr:
415
- # Set the default inverter address
416
- self.comm_addr = 0xf7
460
+ super().__init__(host, port, comm_addr if comm_addr else 0xf7, timeout, retries)
417
461
  self._READ_DEVICE_VERSION_INFO: ProtocolCommand = self._read_command(0x88b8, 0x0021)
418
462
  self._READ_RUNNING_DATA: ProtocolCommand = self._read_command(0x891c, 0x007d)
419
463
  self._READ_METER_DATA: ProtocolCommand = self._read_command(0x8ca0, 0x2d)
@@ -447,19 +491,19 @@ class ET(Inverter):
447
491
  async def read_device_info(self):
448
492
  response = await self._read_from_socket(self._READ_DEVICE_VERSION_INFO)
449
493
  response = response.response_data()
450
- # Modbus registers from offset (35000)
494
+ # Modbus registers from 35000 - 35032
451
495
  self.modbus_version = read_unsigned_int(response, 0)
452
496
  self.rated_power = read_unsigned_int(response, 2)
453
497
  self.ac_output_type = read_unsigned_int(response, 4) # 0: 1-phase, 1: 3-phase (4 wire), 2: 3-phase (3 wire)
454
- self.serial_number = self._decode(response[6:22])
455
- self.model_name = self._decode(response[22:32])
456
- self.dsp1_version = read_unsigned_int(response, 32)
457
- self.dsp2_version = read_unsigned_int(response, 34)
458
- self.dsp_svn_version = read_unsigned_int(response, 36)
459
- self.arm_version = read_unsigned_int(response, 38)
460
- self.arm_svn_version = read_unsigned_int(response, 40)
461
- self.firmware = self._decode(response[42:54])
462
- self.arm_firmware = self._decode(response[54:66])
498
+ self.serial_number = self._decode(response[6:22]) # 35003 - 350010
499
+ self.model_name = self._decode(response[22:32]) # 35011 - 35015
500
+ self.dsp1_version = read_unsigned_int(response, 32) # 35016
501
+ self.dsp2_version = read_unsigned_int(response, 34) # 35017
502
+ self.dsp_svn_version = read_unsigned_int(response, 36) # 35018
503
+ self.arm_version = read_unsigned_int(response, 38) # 35019
504
+ self.arm_svn_version = read_unsigned_int(response, 40) # 35020
505
+ self.firmware = self._decode(response[42:54]) # 35021 - 35027
506
+ self.arm_firmware = self._decode(response[54:66]) # 35027 - 35032
463
507
 
464
508
  if not is_4_mppt(self) and self.rated_power < 15000:
465
509
  # This inverter does not have 4 MPPTs or PV strings
@@ -485,18 +529,24 @@ class ET(Inverter):
485
529
  await self._read_from_socket(self._read_command(47547, 6))
486
530
  self._settings.update({s.id_: s for s in self.__settings_arm_fw_19})
487
531
  except RequestRejectedException as ex:
488
- if ex.message == 'ILLEGAL DATA ADDRESS':
489
- logger.debug("Cannot read EcoModeV2 settings, using to EcoModeV1.")
532
+ if ex.message == ILLEGAL_DATA_ADDRESS:
533
+ logger.debug("EcoModeV2 settings not supported, switching to EcoModeV1.")
490
534
  self._has_eco_mode_v2 = False
535
+ except RequestFailedException:
536
+ logger.debug("Cannot read EcoModeV2 settings, switching to EcoModeV1.")
537
+ self._has_eco_mode_v2 = False
491
538
 
492
539
  # Check and add Peak Shaving settings added in (ETU fw 22)
493
540
  try:
494
541
  await self._read_from_socket(self._read_command(47589, 6))
495
542
  self._settings.update({s.id_: s for s in self.__settings_arm_fw_22})
496
543
  except RequestRejectedException as ex:
497
- if ex.message == 'ILLEGAL DATA ADDRESS':
498
- logger.debug("Cannot read PeakShaving setting, disabling it.")
544
+ if ex.message == ILLEGAL_DATA_ADDRESS:
545
+ logger.debug("PeakShaving setting not supported, disabling it.")
499
546
  self._has_peak_shaving = False
547
+ except RequestFailedException:
548
+ logger.debug("Cannot read _has_peak_shaving settings, disabling it.")
549
+ self._has_peak_shaving = False
500
550
 
501
551
  async def read_runtime_data(self) -> Dict[str, Any]:
502
552
  response = await self._read_from_socket(self._READ_RUNNING_DATA)
@@ -508,8 +558,8 @@ class ET(Inverter):
508
558
  response = await self._read_from_socket(self._READ_BATTERY_INFO)
509
559
  data.update(self._map_response(response, self._sensors_battery))
510
560
  except RequestRejectedException as ex:
511
- if ex.message == 'ILLEGAL DATA ADDRESS':
512
- logger.warning("Cannot read battery values, disabling further attempts.")
561
+ if ex.message == ILLEGAL_DATA_ADDRESS:
562
+ logger.info("Battery values not supported, disabling further attempts.")
513
563
  self._has_battery = False
514
564
  else:
515
565
  raise ex
@@ -519,8 +569,8 @@ class ET(Inverter):
519
569
  data.update(
520
570
  self._map_response(response, self._sensors_battery2))
521
571
  except RequestRejectedException as ex:
522
- if ex.message == 'ILLEGAL DATA ADDRESS':
523
- logger.warning("Cannot read battery 2 values, disabling further attempts.")
572
+ if ex.message == ILLEGAL_DATA_ADDRESS:
573
+ logger.info("Battery 2 values not supported, disabling further attempts.")
524
574
  self._has_battery2 = False
525
575
  else:
526
576
  raise ex
@@ -530,8 +580,8 @@ class ET(Inverter):
530
580
  response = await self._read_from_socket(self._READ_METER_DATA_EXTENDED)
531
581
  data.update(self._map_response(response, self._sensors_meter))
532
582
  except RequestRejectedException as ex:
533
- if ex.message == 'ILLEGAL DATA ADDRESS':
534
- logger.warning("Cannot read extended meter values, disabling further attempts.")
583
+ if ex.message == ILLEGAL_DATA_ADDRESS:
584
+ logger.info("Extended meter values not supported, disabling further attempts.")
535
585
  self._has_meter_extended = False
536
586
  self._sensors_meter = tuple(filter(self._not_extended_meter, self._sensors_meter))
537
587
  response = await self._read_from_socket(self._READ_METER_DATA)
@@ -548,8 +598,8 @@ class ET(Inverter):
548
598
  response = await self._read_from_socket(self._READ_MPPT_DATA)
549
599
  data.update(self._map_response(response, self._sensors_mppt))
550
600
  except RequestRejectedException as ex:
551
- if ex.message == 'ILLEGAL DATA ADDRESS':
552
- logger.warning("Cannot read MPPT values, disabling further attempts.")
601
+ if ex.message == ILLEGAL_DATA_ADDRESS:
602
+ logger.info("MPPT values not supported, disabling further attempts.")
553
603
  self._has_mppt = False
554
604
  else:
555
605
  raise ex
@@ -558,20 +608,35 @@ class ET(Inverter):
558
608
 
559
609
  async def read_setting(self, setting_id: str) -> Any:
560
610
  setting = self._settings.get(setting_id)
561
- if not setting:
562
- raise ValueError(f'Unknown setting "{setting_id}"')
563
- return await self._read_setting(setting)
611
+ if setting:
612
+ return await self._read_setting(setting)
613
+ else:
614
+ if setting_id.startswith("modbus"):
615
+ response = await self._read_from_socket(self._read_command(int(setting_id[7:]), 1))
616
+ return int.from_bytes(response.read(2), byteorder="big", signed=True)
617
+ else:
618
+ raise ValueError(f'Unknown setting "{setting_id}"')
564
619
 
565
620
  async def _read_setting(self, setting: Sensor) -> Any:
566
- count = (setting.size_ + (setting.size_ % 2)) // 2
567
- response = await self._read_from_socket(self._read_command(setting.offset, count))
568
- return setting.read_value(response)
621
+ try:
622
+ count = (setting.size_ + (setting.size_ % 2)) // 2
623
+ response = await self._read_from_socket(self._read_command(setting.offset, count))
624
+ return setting.read_value(response)
625
+ except RequestRejectedException as ex:
626
+ if ex.message == ILLEGAL_DATA_ADDRESS:
627
+ logger.debug("Unsupported setting %s", setting.id_)
628
+ self._settings.pop(setting.id_, None)
629
+ return None
569
630
 
570
631
  async def write_setting(self, setting_id: str, value: Any):
571
632
  setting = self._settings.get(setting_id)
572
- if not setting:
573
- raise ValueError(f'Unknown setting "{setting_id}"')
574
- await self._write_setting(setting, value)
633
+ if setting:
634
+ await self._write_setting(setting, value)
635
+ else:
636
+ if setting_id.startswith("modbus"):
637
+ await self._read_from_socket(self._write_command(int(setting_id[7:]), int(value)))
638
+ else:
639
+ raise ValueError(f'Unknown setting "{setting_id}"')
575
640
 
576
641
  async def _write_setting(self, setting: Sensor, value: Any):
577
642
  if setting.size_ == 1:
@@ -592,7 +657,7 @@ class ET(Inverter):
592
657
  try:
593
658
  value = await self.read_setting(setting.id_)
594
659
  data[setting.id_] = value
595
- except ValueError:
660
+ except (ValueError, RequestFailedException):
596
661
  logger.exception("Error reading setting %s.", setting.id_)
597
662
  data[setting.id_] = None
598
663
  return data
@@ -29,5 +29,19 @@ class RequestRejectedException(InverterError):
29
29
  self.message: str = message
30
30
 
31
31
 
32
+ class PartialResponseException(InverterError):
33
+ """
34
+ Indicates the received response data are incomplete and is probably fragmented to multiple packets.
35
+
36
+ Attributes:
37
+ length -- received data length
38
+ expected -- expected data lenght
39
+ """
40
+
41
+ def __init__(self, lenght: int, expected: int):
42
+ self.length: int = lenght
43
+ self.expected: int = expected
44
+
45
+
32
46
  class MaxRetriesException(InverterError):
33
47
  """Indicates the maximum number of retries has been reached"""
@@ -22,6 +22,7 @@ class SensorKind(Enum):
22
22
  UPS - inverter ups/eps/backup output (e.g. ac voltage of backup/off-grid connected output)
23
23
  BAT - battery (e.g. dc voltage of connected battery pack)
24
24
  GRID - power grid/smart meter (e.g. active power exported to grid)
25
+ BMS - BMS direct data (e.g. dc voltage of)
25
26
  """
26
27
 
27
28
  PV = 1
@@ -29,6 +30,7 @@ class SensorKind(Enum):
29
30
  UPS = 3
30
31
  BAT = 4
31
32
  GRID = 5
33
+ BMS = 6
32
34
 
33
35
 
34
36
  @dataclass
@@ -87,10 +89,9 @@ class Inverter(ABC):
87
89
  """
88
90
 
89
91
  def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
90
- self._protocol: InverterProtocol = self._create_protocol(host, port, timeout, retries)
92
+ self._protocol: InverterProtocol = self._create_protocol(host, port, comm_addr, timeout, retries)
91
93
  self._consecutive_failures_count: int = 0
92
-
93
- self.comm_addr: int = comm_addr
94
+ self.keep_alive: bool = True
94
95
 
95
96
  self.model_name: str | None = None
96
97
  self.serial_number: str | None = None
@@ -107,15 +108,15 @@ class Inverter(ABC):
107
108
 
108
109
  def _read_command(self, offset: int, count: int) -> ProtocolCommand:
109
110
  """Create read protocol command."""
110
- return self._protocol.read_command(self.comm_addr, offset, count)
111
+ return self._protocol.read_command(offset, count)
111
112
 
112
113
  def _write_command(self, register: int, value: int) -> ProtocolCommand:
113
114
  """Create write protocol command."""
114
- return self._protocol.write_command(self.comm_addr, register, value)
115
+ return self._protocol.write_command(register, value)
115
116
 
116
117
  def _write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
117
118
  """Create write multiple protocol command."""
118
- return self._protocol.write_multi_command(self.comm_addr, offset, values)
119
+ return self._protocol.write_multi_command(offset, values)
119
120
 
120
121
  async def _read_from_socket(self, command: ProtocolCommand) -> ProtocolResponse:
121
122
  try:
@@ -129,6 +130,9 @@ class Inverter(ABC):
129
130
  except RequestFailedException as ex:
130
131
  self._consecutive_failures_count += 1
131
132
  raise RequestFailedException(ex.message, self._consecutive_failures_count) from None
133
+ finally:
134
+ if not self.keep_alive:
135
+ self._protocol.close_transport()
132
136
 
133
137
  @abstractmethod
134
138
  async def read_device_info(self):
@@ -268,11 +272,11 @@ class Inverter(ABC):
268
272
  raise NotImplementedError()
269
273
 
270
274
  @staticmethod
271
- def _create_protocol(host: str, port: int, timeout: int, retries: int) -> InverterProtocol:
275
+ def _create_protocol(host: str, port: int, comm_addr: int, timeout: int, retries: int) -> InverterProtocol:
272
276
  if port == 502:
273
- return TcpInverterProtocol(host, port, timeout, retries)
277
+ return TcpInverterProtocol(host, port, comm_addr, timeout, retries)
274
278
  else:
275
- return UdpInverterProtocol(host, port, timeout, retries)
279
+ return UdpInverterProtocol(host, port, comm_addr, timeout, retries)
276
280
 
277
281
  @staticmethod
278
282
  def _map_response(response: ProtocolResponse, sensors: Tuple[Sensor, ...]) -> Dict[str, Any]:
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  from typing import Union
3
3
 
4
- from .exceptions import RequestRejectedException
4
+ from .exceptions import PartialResponseException, RequestRejectedException
5
5
 
6
6
  logger = logging.getLogger(__name__)
7
7
 
@@ -9,9 +9,11 @@ MODBUS_READ_CMD: int = 0x3
9
9
  MODBUS_WRITE_CMD: int = 0x6
10
10
  MODBUS_WRITE_MULTI_CMD: int = 0x10
11
11
 
12
+ ILLEGAL_DATA_ADDRESS: str = 'ILLEGAL DATA ADDRESS'
13
+
12
14
  FAILURE_CODES = {
13
15
  1: "ILLEGAL FUNCTION",
14
- 2: "ILLEGAL DATA ADDRESS",
16
+ 2: ILLEGAL_DATA_ADDRESS,
15
17
  3: "ILLEGAL DATA VALUE",
16
18
  4: "SLAVE DEVICE FAILURE",
17
19
  5: "ACKNOWLEDGE",
@@ -176,8 +178,7 @@ def validate_modbus_rtu_response(data: bytes, cmd: int, offset: int, value: int)
176
178
  return False
177
179
  expected_length = data[4] + 7
178
180
  if len(data) < expected_length:
179
- logger.debug("Response is too short: %d, expected %d.", len(data), expected_length)
180
- return False
181
+ raise PartialResponseException(len(data), expected_length)
181
182
  elif data[3] in (MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD):
182
183
  if len(data) < 10:
183
184
  logger.debug("Response has unexpected length: %d, expected %d.", len(data), 10)
@@ -220,18 +221,18 @@ def validate_modbus_tcp_response(data: bytes, cmd: int, offset: int, value: int)
220
221
  if len(data) <= 8:
221
222
  logger.debug("Response is too short.")
222
223
  return False
224
+ expected_length = int.from_bytes(data[4:6], byteorder='big', signed=False) + 6
225
+ if len(data) < expected_length:
226
+ raise PartialResponseException(len(data), expected_length)
227
+
223
228
  if data[7] == MODBUS_READ_CMD:
224
229
  if data[8] != value * 2:
225
230
  logger.debug("Response has unexpected length: %d, expected %d.", data[8], value * 2)
226
231
  return False
227
- expected_length = data[8] + 9
228
- if len(data) < expected_length:
229
- logger.debug("Response is too short: %d, expected %d.", len(data), expected_length)
230
- return False
231
232
  elif data[7] in (MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD):
232
233
  if len(data) < 12:
233
234
  logger.debug("Response has unexpected length: %d, expected %d.", len(data), 14)
234
- return False
235
+ raise PartialResponseException(len(data), expected_length)
235
236
  response_offset = int.from_bytes(data[8:10], byteorder='big', signed=False)
236
237
  if response_offset != offset:
237
238
  logger.debug("Response has wrong offset: %X, expected %X.", response_offset, offset)