goodwe 0.3.5__py3-none-any.whl → 0.4.0__py3-none-any.whl

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.
goodwe/__init__.py CHANGED
@@ -2,8 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- from typing import Type
6
5
 
6
+ from .const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
7
7
  from .dt import DT
8
8
  from .es import ES
9
9
  from .et import ET
@@ -26,8 +26,8 @@ DISCOVERY_COMMAND = Aa55ProtocolCommand("010200", "0182")
26
26
  _SUPPORTED_PROTOCOLS = [ET, DT, ES]
27
27
 
28
28
 
29
- async def connect(host: str, family: str = None, comm_addr: int = 0, timeout: int = 1, retries: int = 3,
30
- do_discover: bool = True) -> Inverter:
29
+ async def connect(host: str, port: int = GOODWE_UDP_PORT, family: str = None, comm_addr: int = 0, timeout: int = 1,
30
+ retries: int = 3, do_discover: bool = True) -> Inverter:
31
31
  """Contact the inverter at the specified host/port and answer appropriate Inverter instance.
32
32
 
33
33
  The specific inverter family/type will be detected automatically, but it can be passed explicitly.
@@ -41,24 +41,24 @@ async def connect(host: str, family: str = None, comm_addr: int = 0, timeout: in
41
41
 
42
42
  Raise InverterError if unable to contact or recognise supported inverter.
43
43
  """
44
- if family in ET_FAMILY:
45
- inv = ET(host, comm_addr, timeout, retries)
44
+ if family in ET_FAMILY or port == GOODWE_TCP_PORT:
45
+ inv = ET(host, port, comm_addr, timeout, retries)
46
46
  elif family in ES_FAMILY:
47
- inv = ES(host, comm_addr, timeout, retries)
47
+ inv = ES(host, port, comm_addr, timeout, retries)
48
48
  elif family in DT_FAMILY:
49
- inv = DT(host, comm_addr, timeout, retries)
49
+ inv = DT(host, port, comm_addr, timeout, retries)
50
50
  elif do_discover:
51
- return await discover(host, timeout, retries)
51
+ return await discover(host, port, timeout, retries)
52
52
  else:
53
53
  raise InverterError("Specify either an inverter family or set do_discover True")
54
54
 
55
- logger.debug("Connecting to %s family inverter at %s.", family, host)
55
+ logger.debug("Connecting to %s family inverter at %s:%s.", family, host, port)
56
56
  await inv.read_device_info()
57
57
  logger.debug("Connected to inverter %s, S/N:%s.", inv.model_name, inv.serial_number)
58
58
  return inv
59
59
 
60
60
 
61
- async def discover(host: str, timeout: int = 1, retries: int = 3) -> Inverter:
61
+ async def discover(host: str, port: int = GOODWE_UDP_PORT, timeout: int = 1, retries: int = 3) -> Inverter:
62
62
  """Contact the inverter at the specified value and answer appropriate Inverter instance
63
63
 
64
64
  Raise InverterError if unable to contact or recognise supported inverter
@@ -67,28 +67,33 @@ async def discover(host: str, timeout: int = 1, retries: int = 3) -> Inverter:
67
67
 
68
68
  # Try the common AA55C07F0102000241 command first and detect inverter type from serial_number
69
69
  try:
70
- logger.debug("Probing inverter at %s.", host)
71
- response = await DISCOVERY_COMMAND.execute(host, timeout, retries)
70
+ logger.debug("Probing inverter at %s:%s.", host, port)
71
+ response = await DISCOVERY_COMMAND.execute(UdpInverterProtocol(host, port, timeout, retries))
72
72
  response = response.response_data()
73
73
  model_name = response[5:15].decode("ascii").rstrip()
74
74
  serial_number = response[31:47].decode("ascii")
75
75
 
76
- inverter_class: Type[Inverter] | None = None
76
+ i: Inverter | None = None
77
77
  for model_tag in ET_MODEL_TAGS:
78
78
  if model_tag in serial_number:
79
79
  logger.debug("Detected ET/EH/BT/BH/GEH inverter %s, S/N:%s.", model_name, serial_number)
80
- inverter_class = ET
81
- for model_tag in ES_MODEL_TAGS:
82
- if model_tag in serial_number:
83
- logger.debug("Detected ES/EM/BP inverter %s, S/N:%s.", model_name, serial_number)
84
- inverter_class = ES
85
- for model_tag in DT_MODEL_TAGS:
86
- if model_tag in serial_number:
87
- logger.debug("Detected DT/MS/D-NS/XS/GEP inverter %s, S/N:%s.", model_name, serial_number)
88
- inverter_class = DT
89
- if inverter_class:
90
- i = inverter_class(host, 0, timeout, retries)
80
+ i = ET(host, port, 0, timeout, retries)
81
+ break
82
+ if not i:
83
+ for model_tag in ES_MODEL_TAGS:
84
+ if model_tag in serial_number:
85
+ logger.debug("Detected ES/EM/BP inverter %s, S/N:%s.", model_name, serial_number)
86
+ i = ES(host, port, 0, timeout, retries)
87
+ break
88
+ if not i:
89
+ for model_tag in DT_MODEL_TAGS:
90
+ if model_tag in serial_number:
91
+ logger.debug("Detected DT/MS/D-NS/XS/GEP inverter %s, S/N:%s.", model_name, serial_number)
92
+ i = DT(host, port, 0, timeout, retries)
93
+ break
94
+ if i:
91
95
  await i.read_device_info()
96
+ logger.debug("Connected to inverter %s, S/N:%s.", i.model_name, i.serial_number)
92
97
  return i
93
98
 
94
99
  except InverterError as ex:
@@ -96,7 +101,7 @@ async def discover(host: str, timeout: int = 1, retries: int = 3) -> Inverter:
96
101
 
97
102
  # Probe inverter specific protocols
98
103
  for inv in _SUPPORTED_PROTOCOLS:
99
- i = inv(host, 0, timeout, retries)
104
+ i = inv(host, port, 0, timeout, retries)
100
105
  try:
101
106
  logger.debug("Probing %s inverter at %s.", inv.__name__, host)
102
107
  await i.read_device_info()
@@ -119,22 +124,12 @@ async def search_inverters() -> bytes:
119
124
  Raise InverterError if unable to contact any inverter
120
125
  """
121
126
  logger.debug("Searching inverters by broadcast to port 48899")
122
- loop = asyncio.get_running_loop()
123
127
  command = ProtocolCommand("WIFIKIT-214028-READ".encode("utf-8"), lambda r: True)
124
- response_future = loop.create_future()
125
- transport, _ = await loop.create_datagram_endpoint(
126
- lambda: UdpInverterProtocol(response_future, command, 1, 3),
127
- remote_addr=("255.255.255.255", 48899),
128
- allow_broadcast=True,
129
- )
130
128
  try:
131
- await response_future
132
- result = response_future.result()
129
+ result = await command.execute(UdpInverterProtocol("255.255.255.255", 48899, 1, 0))
133
130
  if result is not None:
134
- return result
131
+ return result.response_data()
135
132
  else:
136
133
  raise InverterError("No response received to broadcast request.")
137
134
  except asyncio.CancelledError:
138
135
  raise InverterError("No valid response received to broadcast request.") from None
139
- finally:
140
- transport.close()
goodwe/const.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from typing import Dict
2
2
 
3
+ GOODWE_TCP_PORT = 502
3
4
  GOODWE_UDP_PORT = 8899
4
5
 
5
6
  BATTERY_MODES: Dict[int, str] = {
goodwe/dt.py CHANGED
@@ -7,7 +7,7 @@ from .inverter import Inverter
7
7
  from .inverter import OperationMode
8
8
  from .inverter import SensorKind as Kind
9
9
  from .model import is_3_mppt, is_single_phase
10
- from .protocol import ProtocolCommand, ModbusReadCommand, ModbusWriteCommand, ModbusWriteMultiCommand
10
+ from .protocol import ProtocolCommand
11
11
  from .sensor import *
12
12
 
13
13
 
@@ -122,13 +122,13 @@ class DT(Inverter):
122
122
  Integer("grid_export_limit", 40336, "Grid Export Limit", "%", Kind.GRID),
123
123
  )
124
124
 
125
- def __init__(self, host: str, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
126
- super().__init__(host, comm_addr, timeout, retries)
125
+ 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
127
  if not self.comm_addr:
128
128
  # Set the default inverter address
129
129
  self.comm_addr = 0x7f
130
- self._READ_DEVICE_VERSION_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x7531, 0x0028)
131
- self._READ_DEVICE_RUNNING_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x7594, 0x0049)
130
+ self._READ_DEVICE_VERSION_INFO: ProtocolCommand = self._read_command(0x7531, 0x0028)
131
+ self._READ_DEVICE_RUNNING_DATA: ProtocolCommand = self._read_command(0x7594, 0x0049)
132
132
  self._sensors = self.__all_sensors
133
133
  self._settings: dict[str, Sensor] = {s.id_: s for s in self.__all_settings}
134
134
 
@@ -180,7 +180,7 @@ class DT(Inverter):
180
180
  if not setting:
181
181
  raise ValueError(f'Unknown setting "{setting_id}"')
182
182
  count = (setting.size_ + (setting.size_ % 2)) // 2
183
- response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, count))
183
+ response = await self._read_from_socket(self._read_command(setting.offset, count))
184
184
  return setting.read_value(response)
185
185
 
186
186
  async def write_setting(self, setting_id: str, value: Any):
@@ -190,9 +190,9 @@ class DT(Inverter):
190
190
  raw_value = setting.encode_value(value)
191
191
  if len(raw_value) <= 2:
192
192
  value = int.from_bytes(raw_value, byteorder="big", signed=True)
193
- await self._read_from_socket(ModbusWriteCommand(self.comm_addr, setting.offset, value))
193
+ await self._read_from_socket(self._write_command(setting.offset, value))
194
194
  else:
195
- await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, setting.offset, raw_value))
195
+ await self._read_from_socket(self._write_multi_command(setting.offset, raw_value))
196
196
 
197
197
  async def read_settings_data(self) -> Dict[str, Any]:
198
198
  data = {}
goodwe/es.py CHANGED
@@ -7,8 +7,7 @@ from .exceptions import InverterError
7
7
  from .inverter import Inverter
8
8
  from .inverter import OperationMode
9
9
  from .inverter import SensorKind as Kind
10
- from .protocol import ProtocolCommand, Aa55ProtocolCommand, Aa55ReadCommand, Aa55WriteCommand, Aa55WriteMultiCommand, \
11
- ModbusReadCommand, ModbusWriteCommand, ModbusWriteMultiCommand
10
+ from .protocol import ProtocolCommand, Aa55ProtocolCommand, Aa55ReadCommand, Aa55WriteCommand, Aa55WriteMultiCommand
12
11
  from .sensor import *
13
12
 
14
13
  logger = logging.getLogger(__name__)
@@ -168,8 +167,8 @@ class ES(Inverter):
168
167
  ByteH("eco_mode_4_switch", 47567, "Eco Mode Group 4 Switch"),
169
168
  )
170
169
 
171
- def __init__(self, host: str, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
172
- super().__init__(host, comm_addr, timeout, retries)
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)
173
172
  if not self.comm_addr:
174
173
  # Set the default inverter address
175
174
  self.comm_addr = 0xf7
@@ -228,7 +227,7 @@ class ES(Inverter):
228
227
  async def _read_setting(self, setting: Sensor) -> Any:
229
228
  count = (setting.size_ + (setting.size_ % 2)) // 2
230
229
  if self._is_modbus_setting(setting):
231
- response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, count))
230
+ response = await self._read_from_socket(self._read_command(setting.offset, count))
232
231
  return setting.read_value(response)
233
232
  else:
234
233
  response = await self._read_from_socket(Aa55ReadCommand(setting.offset, count))
@@ -249,7 +248,7 @@ class ES(Inverter):
249
248
  if setting.size_ == 1:
250
249
  # modbus can address/store only 16 bit values, read the other 8 bytes
251
250
  if self._is_modbus_setting(setting):
252
- response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, 1))
251
+ response = await self._read_from_socket(self._read_command(setting.offset, 1))
253
252
  raw_value = setting.encode_value(value, response.response_data()[0:2])
254
253
  else:
255
254
  response = await self._read_from_socket(Aa55ReadCommand(setting.offset, 1))
@@ -259,12 +258,12 @@ class ES(Inverter):
259
258
  if len(raw_value) <= 2:
260
259
  value = int.from_bytes(raw_value, byteorder="big", signed=True)
261
260
  if self._is_modbus_setting(setting):
262
- await self._read_from_socket(ModbusWriteCommand(self.comm_addr, setting.offset, value))
261
+ await self._read_from_socket(self._write_command(setting.offset, value))
263
262
  else:
264
263
  await self._read_from_socket(Aa55WriteCommand(setting.offset, value))
265
264
  else:
266
265
  if self._is_modbus_setting(setting):
267
- await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, setting.offset, raw_value))
266
+ await self._read_from_socket(self._write_multi_command(setting.offset, raw_value))
268
267
  else:
269
268
  await self._read_from_socket(Aa55WriteMultiCommand(setting.offset, raw_value))
270
269
 
@@ -291,7 +290,7 @@ class ES(Inverter):
291
290
  result.remove(OperationMode.ECO_DISCHARGE)
292
291
  return tuple(result)
293
292
 
294
- async def get_operation_mode(self) -> OperationMode:
293
+ async def get_operation_mode(self) -> OperationMode | None:
295
294
  mode_id = await self.read_setting('work_mode')
296
295
  try:
297
296
  mode = OperationMode(mode_id)
goodwe/et.py CHANGED
@@ -3,12 +3,12 @@ from __future__ import annotations
3
3
  import logging
4
4
  from typing import Tuple
5
5
 
6
- from .exceptions import RequestFailedException, RequestRejectedException
6
+ from .exceptions import RequestRejectedException
7
7
  from .inverter import Inverter
8
8
  from .inverter import OperationMode
9
9
  from .inverter import SensorKind as Kind
10
10
  from .model import is_2_battery, is_4_mppt, is_745_platform, is_single_phase
11
- from .protocol import ProtocolCommand, ModbusReadCommand, ModbusWriteCommand, ModbusWriteMultiCommand
11
+ from .protocol import ProtocolCommand
12
12
  from .sensor import *
13
13
 
14
14
  logger = logging.getLogger(__name__)
@@ -152,6 +152,10 @@ class ET(Inverter):
152
152
  read_bytes4_signed(data, 35182) -
153
153
  read_bytes2_signed(data, 35140),
154
154
  "House Consumption", "W", Kind.AC),
155
+
156
+ # Power4S("pbattery2", 35264, "Battery2 Power", Kind.BAT),
157
+ # Integer("battery2_mode", 35266, "Battery2 Mode code", "", Kind.BAT),
158
+ # Enum2("battery2_mode_label", 35184, BATTERY_MODES, "Battery2 Mode", Kind.BAT),
155
159
  )
156
160
 
157
161
  # Modbus registers from offset 0x9088 (37000)
@@ -405,18 +409,18 @@ class ET(Inverter):
405
409
  Integer("eco_mode_enable", 47612, "Eco Mode Switch"),
406
410
  )
407
411
 
408
- def __init__(self, host: str, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
409
- super().__init__(host, comm_addr, timeout, retries)
412
+ 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)
410
414
  if not self.comm_addr:
411
415
  # Set the default inverter address
412
416
  self.comm_addr = 0xf7
413
- self._READ_DEVICE_VERSION_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x88b8, 0x0021)
414
- self._READ_RUNNING_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x891c, 0x007d)
415
- self._READ_METER_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x8ca0, 0x2d)
416
- self._READ_METER_DATA_EXTENDED: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x8ca0, 0x3a)
417
- self._READ_BATTERY_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x9088, 0x0018)
418
- self._READ_BATTERY2_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x9858, 0x0016)
419
- self._READ_MPPT_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x89e5, 0x3d)
417
+ self._READ_DEVICE_VERSION_INFO: ProtocolCommand = self._read_command(0x88b8, 0x0021)
418
+ self._READ_RUNNING_DATA: ProtocolCommand = self._read_command(0x891c, 0x007d)
419
+ self._READ_METER_DATA: ProtocolCommand = self._read_command(0x8ca0, 0x2d)
420
+ self._READ_METER_DATA_EXTENDED: ProtocolCommand = self._read_command(0x8ca0, 0x3a)
421
+ self._READ_BATTERY_INFO: ProtocolCommand = self._read_command(0x9088, 0x0018)
422
+ self._READ_BATTERY2_INFO: ProtocolCommand = self._read_command(0x9858, 0x0016)
423
+ self._READ_MPPT_DATA: ProtocolCommand = self._read_command(0x89e5, 0x3d)
420
424
  self._has_eco_mode_v2: bool = True
421
425
  self._has_peak_shaving: bool = True
422
426
  self._has_battery: bool = True
@@ -478,27 +482,21 @@ class ET(Inverter):
478
482
 
479
483
  # Check and add EcoModeV2 settings added in (ETU fw 19)
480
484
  try:
481
- await self._read_from_socket(ModbusReadCommand(self.comm_addr, 47547, 6))
485
+ await self._read_from_socket(self._read_command(47547, 6))
482
486
  self._settings.update({s.id_: s for s in self.__settings_arm_fw_19})
483
487
  except RequestRejectedException as ex:
484
488
  if ex.message == 'ILLEGAL DATA ADDRESS':
485
- logger.debug("EcoModeV2 settings not supported, switching to EcoModeV1.")
489
+ logger.debug("Cannot read EcoModeV2 settings, using to EcoModeV1.")
486
490
  self._has_eco_mode_v2 = False
487
- except RequestFailedException as ex:
488
- logger.debug("Cannot read EcoModeV2 settings, switching to EcoModeV1.")
489
- self._has_eco_mode_v2 = False
490
491
 
491
492
  # Check and add Peak Shaving settings added in (ETU fw 22)
492
493
  try:
493
- await self._read_from_socket(ModbusReadCommand(self.comm_addr, 47589, 6))
494
+ await self._read_from_socket(self._read_command(47589, 6))
494
495
  self._settings.update({s.id_: s for s in self.__settings_arm_fw_22})
495
496
  except RequestRejectedException as ex:
496
497
  if ex.message == 'ILLEGAL DATA ADDRESS':
497
- logger.debug("PeakShaving setting not supported, disabling it.")
498
+ logger.debug("Cannot read PeakShaving setting, disabling it.")
498
499
  self._has_peak_shaving = False
499
- except RequestFailedException as ex:
500
- logger.debug("Cannot read _has_peak_shaving settings, disabling it.")
501
- self._has_peak_shaving = False
502
500
 
503
501
  async def read_runtime_data(self) -> Dict[str, Any]:
504
502
  response = await self._read_from_socket(self._READ_RUNNING_DATA)
@@ -511,7 +509,7 @@ class ET(Inverter):
511
509
  data.update(self._map_response(response, self._sensors_battery))
512
510
  except RequestRejectedException as ex:
513
511
  if ex.message == 'ILLEGAL DATA ADDRESS':
514
- logger.warning("Battery values not supported, disabling further attempts.")
512
+ logger.warning("Cannot read battery values, disabling further attempts.")
515
513
  self._has_battery = False
516
514
  else:
517
515
  raise ex
@@ -522,7 +520,7 @@ class ET(Inverter):
522
520
  self._map_response(response, self._sensors_battery2))
523
521
  except RequestRejectedException as ex:
524
522
  if ex.message == 'ILLEGAL DATA ADDRESS':
525
- logger.warning("Battery 2 values not supported, disabling further attempts.")
523
+ logger.warning("Cannot read battery 2 values, disabling further attempts.")
526
524
  self._has_battery2 = False
527
525
  else:
528
526
  raise ex
@@ -533,7 +531,7 @@ class ET(Inverter):
533
531
  data.update(self._map_response(response, self._sensors_meter))
534
532
  except RequestRejectedException as ex:
535
533
  if ex.message == 'ILLEGAL DATA ADDRESS':
536
- logger.warning("Extended meter values not supported, disabling further attempts.")
534
+ logger.warning("Cannot read extended meter values, disabling further attempts.")
537
535
  self._has_meter_extended = False
538
536
  self._sensors_meter = tuple(filter(self._not_extended_meter, self._sensors_meter))
539
537
  response = await self._read_from_socket(self._READ_METER_DATA)
@@ -551,7 +549,7 @@ class ET(Inverter):
551
549
  data.update(self._map_response(response, self._sensors_mppt))
552
550
  except RequestRejectedException as ex:
553
551
  if ex.message == 'ILLEGAL DATA ADDRESS':
554
- logger.warning("MPPT values not supported, disabling further attempts.")
552
+ logger.warning("Cannot read MPPT values, disabling further attempts.")
555
553
  self._has_mppt = False
556
554
  else:
557
555
  raise ex
@@ -566,7 +564,7 @@ class ET(Inverter):
566
564
 
567
565
  async def _read_setting(self, setting: Sensor) -> Any:
568
566
  count = (setting.size_ + (setting.size_ % 2)) // 2
569
- response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, count))
567
+ response = await self._read_from_socket(self._read_command(setting.offset, count))
570
568
  return setting.read_value(response)
571
569
 
572
570
  async def write_setting(self, setting_id: str, value: Any):
@@ -578,15 +576,15 @@ class ET(Inverter):
578
576
  async def _write_setting(self, setting: Sensor, value: Any):
579
577
  if setting.size_ == 1:
580
578
  # modbus can address/store only 16 bit values, read the other 8 bytes
581
- response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, 1))
579
+ response = await self._read_from_socket(self._read_command(setting.offset, 1))
582
580
  raw_value = setting.encode_value(value, response.response_data()[0:2])
583
581
  else:
584
582
  raw_value = setting.encode_value(value)
585
583
  if len(raw_value) <= 2:
586
584
  value = int.from_bytes(raw_value, byteorder="big", signed=True)
587
- await self._read_from_socket(ModbusWriteCommand(self.comm_addr, setting.offset, value))
585
+ await self._read_from_socket(self._write_command(setting.offset, value))
588
586
  else:
589
- await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, setting.offset, raw_value))
587
+ await self._read_from_socket(self._write_multi_command(setting.offset, raw_value))
590
588
 
591
589
  async def read_settings_data(self) -> Dict[str, Any]:
592
590
  data = {}
@@ -617,7 +615,7 @@ class ET(Inverter):
617
615
  result.remove(OperationMode.ECO_DISCHARGE)
618
616
  return tuple(result)
619
617
 
620
- async def get_operation_mode(self) -> OperationMode:
618
+ async def get_operation_mode(self) -> OperationMode | None:
621
619
  mode_id = await self.read_setting('work_mode')
622
620
  try:
623
621
  mode = OperationMode(mode_id)
@@ -705,8 +703,8 @@ class ET(Inverter):
705
703
  return tuple(self._settings.values())
706
704
 
707
705
  async def _clear_battery_mode_param(self) -> None:
708
- await self._read_from_socket(ModbusWriteCommand(self.comm_addr, 0xb9ad, 1))
706
+ await self._read_from_socket(self._write_command(0xb9ad, 1))
709
707
 
710
708
  async def _set_offline(self, mode: bool) -> None:
711
709
  value = bytes.fromhex('00070000') if mode else bytes.fromhex('00010000')
712
- await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, 0xb997, value))
710
+ await self._read_from_socket(self._write_multi_command(0xb997, value))
goodwe/inverter.py CHANGED
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
3
  import logging
5
4
  from abc import ABC, abstractmethod
6
5
  from dataclasses import dataclass
@@ -8,7 +7,7 @@ from enum import Enum, IntEnum
8
7
  from typing import Any, Callable, Dict, Tuple, Optional
9
8
 
10
9
  from .exceptions import MaxRetriesException, RequestFailedException
11
- from .protocol import ProtocolCommand, ProtocolResponse
10
+ from .protocol import InverterProtocol, ProtocolCommand, ProtocolResponse, TcpInverterProtocol, UdpInverterProtocol
12
11
 
13
12
  logger = logging.getLogger(__name__)
14
13
 
@@ -87,15 +86,12 @@ class Inverter(ABC):
87
86
  Represents the inverter state and its basic behavior
88
87
  """
89
88
 
90
- def __init__(self, host: str, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
91
- self.host: str = host
92
- self.comm_addr: int = comm_addr
93
- self.timeout: int = timeout
94
- self.retries: int = retries
95
- self._running_loop: asyncio.AbstractEventLoop | None = None
96
- self._lock: asyncio.Lock | None = None
89
+ 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)
97
91
  self._consecutive_failures_count: int = 0
98
92
 
93
+ self.comm_addr: int = comm_addr
94
+
99
95
  self.model_name: str | None = None
100
96
  self.serial_number: str | None = None
101
97
  self.rated_power: int = 0
@@ -109,36 +105,30 @@ class Inverter(ABC):
109
105
  self.arm_version: int = 0
110
106
  self.arm_svn_version: int | None = None
111
107
 
112
- def _ensure_lock(self) -> asyncio.Lock:
113
- """Validate (or create) asyncio Lock.
108
+ def _read_command(self, offset: int, count: int) -> ProtocolCommand:
109
+ """Create read protocol command."""
110
+ return self._protocol.read_command(self.comm_addr, offset, count)
114
111
 
115
- The asyncio.Lock must always be created from within's asyncio loop,
116
- so it cannot be eagerly created in constructor.
117
- Additionally, since asyncio.run() creates and closes its own loop,
118
- the lock's scope (its creating loop) mus be verified to support proper
119
- behavior in subsequent asyncio.run() invocations.
120
- """
121
- if self._lock and self._running_loop == asyncio.get_event_loop():
122
- return self._lock
123
- else:
124
- logger.debug("Creating lock instance for current event loop.")
125
- self._lock = asyncio.Lock()
126
- self._running_loop = asyncio.get_event_loop()
127
- return self._lock
112
+ def _write_command(self, register: int, value: int) -> ProtocolCommand:
113
+ """Create write protocol command."""
114
+ return self._protocol.write_command(self.comm_addr, register, value)
115
+
116
+ def _write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
117
+ """Create write multiple protocol command."""
118
+ return self._protocol.write_multi_command(self.comm_addr, offset, values)
128
119
 
129
120
  async def _read_from_socket(self, command: ProtocolCommand) -> ProtocolResponse:
130
- async with self._ensure_lock():
131
- try:
132
- result = await command.execute(self.host, self.timeout, self.retries)
133
- self._consecutive_failures_count = 0
134
- return result
135
- except MaxRetriesException:
136
- self._consecutive_failures_count += 1
137
- raise RequestFailedException(f'No valid response received even after {self.retries} retries',
138
- self._consecutive_failures_count)
139
- except RequestFailedException as ex:
140
- self._consecutive_failures_count += 1
141
- raise RequestFailedException(ex.message, self._consecutive_failures_count)
121
+ try:
122
+ result = await command.execute(self._protocol)
123
+ self._consecutive_failures_count = 0
124
+ return result
125
+ except MaxRetriesException:
126
+ self._consecutive_failures_count += 1
127
+ raise RequestFailedException(f'No valid response received even after {self._protocol.retries} retries',
128
+ self._consecutive_failures_count) from None
129
+ except RequestFailedException as ex:
130
+ self._consecutive_failures_count += 1
131
+ raise RequestFailedException(ex.message, self._consecutive_failures_count) from None
142
132
 
143
133
  @abstractmethod
144
134
  async def read_device_info(self):
@@ -190,8 +180,8 @@ class Inverter(ABC):
190
180
  self, command: bytes, validator: Callable[[bytes], bool] = lambda x: True
191
181
  ) -> ProtocolResponse:
192
182
  """
193
- Send low level udp command (as bytes).
194
- Answer command's raw response data.
183
+ Send low level command (as bytes).
184
+ Answer ProtocolResponse with command's raw response data.
195
185
  """
196
186
  return await self._read_from_socket(ProtocolCommand(command, validator))
197
187
 
@@ -277,6 +267,13 @@ class Inverter(ABC):
277
267
  """
278
268
  raise NotImplementedError()
279
269
 
270
+ @staticmethod
271
+ def _create_protocol(host: str, port: int, timeout: int, retries: int) -> InverterProtocol:
272
+ if port == 502:
273
+ return TcpInverterProtocol(host, port, timeout, retries)
274
+ else:
275
+ return UdpInverterProtocol(host, port, timeout, retries)
276
+
280
277
  @staticmethod
281
278
  def _map_response(response: ProtocolResponse, sensors: Tuple[Sensor, ...]) -> Dict[str, Any]:
282
279
  """Process the response data and return dictionary with runtime values"""
goodwe/modbus.py CHANGED
@@ -52,9 +52,9 @@ def _modbus_checksum(data: Union[bytearray, bytes]) -> int:
52
52
  return crc
53
53
 
54
54
 
55
- def create_modbus_request(comm_addr: int, cmd: int, offset: int, value: int) -> bytes:
55
+ def create_modbus_rtu_request(comm_addr: int, cmd: int, offset: int, value: int) -> bytes:
56
56
  """
57
- Create modbus request.
57
+ Create modbus RTU request.
58
58
  data[0] is inverter address
59
59
  data[1] is modbus command
60
60
  data[2:3] is command offset parameter
@@ -74,9 +74,36 @@ def create_modbus_request(comm_addr: int, cmd: int, offset: int, value: int) ->
74
74
  return bytes(data)
75
75
 
76
76
 
77
- def create_modbus_multi_request(comm_addr: int, cmd: int, offset: int, values: bytes) -> bytes:
77
+ def create_modbus_tcp_request(comm_addr: int, cmd: int, offset: int, value: int) -> bytes:
78
78
  """
79
- Create modbus (multi value) request.
79
+ Create modbus TCP request.
80
+ data[0:1] is transaction identifier
81
+ data[2:3] is protocol identifier (0)
82
+ data[4:5] message length
83
+ data[6] is inverter address
84
+ data[7] is modbus command
85
+ data[8:9] is command offset parameter
86
+ data[10:11] is command value parameter
87
+ """
88
+ data: bytearray = bytearray(12)
89
+ data[0] = 0
90
+ data[1] = 1 # Not transaction ID support yet
91
+ data[2] = 0
92
+ data[3] = 0
93
+ data[4] = 0
94
+ data[5] = 6
95
+ data[6] = comm_addr
96
+ data[7] = cmd
97
+ data[8] = (offset >> 8) & 0xFF
98
+ data[9] = offset & 0xFF
99
+ data[10] = (value >> 8) & 0xFF
100
+ data[11] = value & 0xFF
101
+ return bytes(data)
102
+
103
+
104
+ def create_modbus_rtu_multi_request(comm_addr: int, cmd: int, offset: int, values: bytes) -> bytes:
105
+ """
106
+ Create modbus RTU (multi value) request.
80
107
  data[0] is inverter address
81
108
  data[1] is modbus command
82
109
  data[2:3] is command offset parameter
@@ -100,9 +127,40 @@ def create_modbus_multi_request(comm_addr: int, cmd: int, offset: int, values: b
100
127
  return bytes(data)
101
128
 
102
129
 
103
- def validate_modbus_response(data: bytes, cmd: int, offset: int, value: int) -> bool:
130
+ def create_modbus_tcp_multi_request(comm_addr: int, cmd: int, offset: int, values: bytes) -> bytes:
131
+ """
132
+ Create modbus TCP (multi value) request.
133
+ data[0:1] is transaction identifier
134
+ data[2:3] is protocol identifier (0)
135
+ data[4:5] message length
136
+ data[6] is inverter address
137
+ data[7] is modbus command
138
+ data[8:9] is command offset parameter
139
+ data[10:11] is number of registers
140
+ data[12] is number of bytes
141
+ data[13-n] is data payload
104
142
  """
105
- Validate the modbus response.
143
+ data: bytearray = bytearray(13)
144
+ data[0] = 0
145
+ data[1] = 1 # Not transaction ID support yet
146
+ data[2] = 0
147
+ data[3] = 0
148
+ data[4] = 0
149
+ data[5] = 7 + len(values)
150
+ data[6] = comm_addr
151
+ data[7] = cmd
152
+ data[8] = (offset >> 8) & 0xFF
153
+ data[9] = offset & 0xFF
154
+ data[10] = 0
155
+ data[11] = len(values) // 2
156
+ data[12] = len(values)
157
+ data.extend(values)
158
+ return bytes(data)
159
+
160
+
161
+ def validate_modbus_rtu_response(data: bytes, cmd: int, offset: int, value: int) -> bool:
162
+ """
163
+ Validate the modbus RTU response.
106
164
  data[0:1] is header
107
165
  data[2] is source address
108
166
  data[3] is command return type
@@ -147,3 +205,45 @@ def validate_modbus_response(data: bytes, cmd: int, offset: int, value: int) ->
147
205
  raise RequestRejectedException(failure_code)
148
206
 
149
207
  return True
208
+
209
+
210
+ def validate_modbus_tcp_response(data: bytes, cmd: int, offset: int, value: int) -> bool:
211
+ """
212
+ Validate the modbus TCP response.
213
+ data[0:1] is transaction identifier
214
+ data[2:3] is protocol identifier (0)
215
+ data[4:5] message length
216
+ data[6] is source address
217
+ data[7] is command return type
218
+ data[8] is response payload length (for read commands)
219
+ """
220
+ if len(data) <= 8:
221
+ logger.debug("Response is too short.")
222
+ return False
223
+ if data[7] == MODBUS_READ_CMD:
224
+ if data[8] != value * 2:
225
+ logger.debug("Response has unexpected length: %d, expected %d.", data[8], value * 2)
226
+ 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
+ elif data[7] in (MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD):
232
+ if len(data) < 12:
233
+ logger.debug("Response has unexpected length: %d, expected %d.", len(data), 14)
234
+ return False
235
+ response_offset = int.from_bytes(data[8:10], byteorder='big', signed=False)
236
+ if response_offset != offset:
237
+ logger.debug("Response has wrong offset: %X, expected %X.", response_offset, offset)
238
+ return False
239
+ response_value = int.from_bytes(data[10:12], byteorder='big', signed=True)
240
+ if response_value != value:
241
+ logger.debug("Response has wrong value: %X, expected %X.", response_value, value)
242
+ return False
243
+
244
+ if data[7] != cmd:
245
+ failure_code = FAILURE_CODES.get(data[8], "UNKNOWN")
246
+ logger.debug("Response is command failure: %s.", FAILURE_CODES.get(data[8], "UNKNOWN"))
247
+ raise RequestRejectedException(failure_code)
248
+
249
+ return True
goodwe/protocol.py CHANGED
@@ -6,80 +6,306 @@ import logging
6
6
  from asyncio.futures import Future
7
7
  from typing import Tuple, Optional, Callable
8
8
 
9
- from .const import GOODWE_UDP_PORT
10
9
  from .exceptions import MaxRetriesException, RequestFailedException, RequestRejectedException
11
- from .modbus import create_modbus_request, create_modbus_multi_request, validate_modbus_response, MODBUS_READ_CMD, \
10
+ from .modbus import create_modbus_rtu_request, create_modbus_rtu_multi_request, create_modbus_tcp_request, \
11
+ create_modbus_tcp_multi_request, validate_modbus_rtu_response, validate_modbus_tcp_response, MODBUS_READ_CMD, \
12
12
  MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
16
 
17
- class UdpInverterProtocol(asyncio.DatagramProtocol):
18
- def __init__(
19
- self,
20
- response_future: Future,
21
- command: ProtocolCommand,
22
- timeout: int,
23
- retries: int
24
- ):
25
- super().__init__()
26
- self.response_future: Future = response_future
27
- self.command: ProtocolCommand = command
17
+ class InverterProtocol:
18
+
19
+ def __init__(self, host: str, port: int, timeout: int, retries: int):
20
+ self._host: str = host
21
+ self._port: int = port
22
+ self._running_loop: asyncio.AbstractEventLoop | None = None
23
+ self._lock: asyncio.Lock | None = None
24
+ self._timer: asyncio.TimerHandle | None = None
25
+ self.timeout: int = timeout
26
+ self.retries: int = retries
27
+ self.protocol: asyncio.Protocol | None = None
28
+ self.response_future: Future | None = None
29
+ self.command: ProtocolCommand | None = None
30
+
31
+ def _ensure_lock(self) -> asyncio.Lock:
32
+ """Validate (or create) asyncio Lock.
33
+
34
+ The asyncio.Lock must always be created from within's asyncio loop,
35
+ so it cannot be eagerly created in constructor.
36
+ Additionally, since asyncio.run() creates and closes its own loop,
37
+ the lock's scope (its creating loop) mus be verified to support proper
38
+ behavior in subsequent asyncio.run() invocations.
39
+ """
40
+ if self._lock and self._running_loop == asyncio.get_event_loop():
41
+ return self._lock
42
+ else:
43
+ logger.debug("Creating lock instance for current event loop.")
44
+ self._lock = asyncio.Lock()
45
+ self._running_loop = asyncio.get_event_loop()
46
+ self._close_transport()
47
+ return self._lock
48
+
49
+ def _close_transport(self) -> None:
50
+ raise NotImplementedError()
51
+
52
+ async def send_request(self, command: ProtocolCommand) -> Future:
53
+ raise NotImplementedError()
54
+
55
+ def read_command(self, comm_addr: int, offset: int, count: int) -> ProtocolCommand:
56
+ """Create read protocol command."""
57
+ raise NotImplementedError()
58
+
59
+ def write_command(self, comm_addr: int, register: int, value: int) -> ProtocolCommand:
60
+ """Create write protocol command."""
61
+ raise NotImplementedError()
62
+
63
+ def write_multi_command(self, comm_addr: int, offset: int, values: bytes) -> ProtocolCommand:
64
+ """Create write multiple protocol command."""
65
+ raise NotImplementedError()
66
+
67
+
68
+ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
69
+ def __init__(self, host: str, port: int, timeout: int = 1, retries: int = 3):
70
+ super().__init__(host, port, timeout, retries)
28
71
  self._transport: asyncio.transports.DatagramTransport | None = None
29
- self._retry_timeout: int = timeout
30
- self._max_retries: int = retries
31
- self._retries: int = 0
72
+ self._retry: int = 0
73
+
74
+ def read_command(self, comm_addr: int, offset: int, count: int) -> ProtocolCommand:
75
+ """Create read protocol command."""
76
+ return ModbusRtuReadCommand(comm_addr, offset, count)
77
+
78
+ def write_command(self, comm_addr: int, register: int, value: int) -> ProtocolCommand:
79
+ """Create write protocol command."""
80
+ return ModbusRtuWriteCommand(comm_addr, register, value)
81
+
82
+ def write_multi_command(self, comm_addr: int, offset: int, values: bytes) -> ProtocolCommand:
83
+ """Create write multiple protocol command."""
84
+ return ModbusRtuWriteMultiCommand(comm_addr, offset, values)
85
+
86
+ async def _connect(self) -> None:
87
+ if not self._transport or self._transport.is_closing():
88
+ self._transport, self.protocol = await asyncio.get_running_loop().create_datagram_endpoint(
89
+ lambda: self,
90
+ remote_addr=(self._host, self._port),
91
+ )
32
92
 
33
93
  def connection_made(self, transport: asyncio.DatagramTransport) -> None:
34
94
  """On connection made"""
35
95
  self._transport = transport
36
- self._send_request()
37
96
 
38
97
  def connection_lost(self, exc: Optional[Exception]) -> None:
39
98
  """On connection lost"""
40
- if exc is not None:
99
+ if exc:
41
100
  logger.debug("Socket closed with error: %s.", exc)
42
- # Cancel Future on connection lost
43
- if not self.response_future.done():
44
- self.response_future.cancel()
101
+ else:
102
+ logger.debug("Socket closed.")
103
+ self._close_transport()
45
104
 
46
105
  def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None:
47
106
  """On datagram received"""
107
+ if self._timer:
108
+ self._timer.cancel()
109
+ self._timer = None
48
110
  try:
49
111
  if self.command.validator(data):
50
112
  logger.debug("Received: %s", data.hex())
51
113
  self.response_future.set_result(data)
52
114
  else:
53
115
  logger.debug("Received invalid response: %s", data.hex())
54
- self._retries += 1
55
- self._send_request()
116
+ asyncio.get_running_loop().call_soon(self._retry_mechanism)
56
117
  except RequestRejectedException as ex:
57
118
  logger.debug("Received exception response: %s", data.hex())
58
119
  self.response_future.set_exception(ex)
120
+ self._close_transport()
59
121
 
60
122
  def error_received(self, exc: Exception) -> None:
61
123
  """On error received"""
62
124
  logger.debug("Received error: %s", exc)
63
125
  self.response_future.set_exception(exc)
126
+ self._close_transport()
127
+
128
+ async def send_request(self, command: ProtocolCommand) -> Future:
129
+ """Send message via transport"""
130
+ async with self._ensure_lock():
131
+ await self._connect()
132
+ response_future = asyncio.get_running_loop().create_future()
133
+ self._retry = 0
134
+ self._send_request(command, response_future)
135
+ await response_future
136
+ return response_future
64
137
 
65
- def _send_request(self) -> None:
138
+ def _send_request(self, command: ProtocolCommand, response_future: Future) -> None:
66
139
  """Send message via transport"""
140
+ self.command = command
141
+ self.response_future = response_future
67
142
  logger.debug("Sending: %s%s", self.command,
68
- f' - retry #{self._retries}/{self._max_retries}' if self._retries > 0 else '')
143
+ f' - retry #{self._retry}/{self.retries}' if self._retry > 0 else '')
69
144
  self._transport.sendto(self.command.request)
70
- asyncio.get_event_loop().call_later(self._retry_timeout, self._retry_mechanism)
145
+ self._timer = asyncio.get_running_loop().call_later(self.timeout, self._retry_mechanism)
71
146
 
72
147
  def _retry_mechanism(self) -> None:
73
148
  """Retry mechanism to prevent hanging transport"""
74
149
  if self.response_future.done():
75
- self._transport.close()
76
- elif self._retries < self._max_retries:
77
- logger.debug("Failed to receive response to %s in time (%ds).", self.command, self._retry_timeout)
78
- self._retries += 1
79
- self._send_request()
150
+ logger.debug("Response already received.")
151
+ elif self._retry < self.retries:
152
+ if self._timer:
153
+ logger.debug("Failed to receive response to %s in time (%ds).", self.command, self.timeout)
154
+ self._retry += 1
155
+ self._send_request(self.command, self.response_future)
80
156
  else:
81
- logger.debug("Max number of retries (%d) reached, request %s failed.", self._max_retries, self.command)
157
+ logger.debug("Max number of retries (%d) reached, request %s failed.", self.retries, self.command)
82
158
  self.response_future.set_exception(MaxRetriesException)
159
+ self._close_transport()
160
+
161
+ def _close_transport(self) -> None:
162
+ if self._transport:
163
+ try:
164
+ self._transport.close()
165
+ except RuntimeError:
166
+ logger.debug("Failed to close transport.")
167
+ self._transport = None
168
+ # Cancel Future on connection close
169
+ if self.response_future and not self.response_future.done():
170
+ self.response_future.cancel()
171
+
172
+
173
+ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
174
+ def __init__(self, host: str, port: int, timeout: int = 1, retries: int = 0):
175
+ super().__init__(host, port, timeout, retries)
176
+ self._transport: asyncio.transports.Transport | None = None
177
+ self._retry: int = 0
178
+
179
+ def read_command(self, comm_addr: int, offset: int, count: int) -> ProtocolCommand:
180
+ """Create read protocol command."""
181
+ return ModbusTcpReadCommand(comm_addr, offset, count)
182
+
183
+ def write_command(self, comm_addr: int, register: int, value: int) -> ProtocolCommand:
184
+ """Create write protocol command."""
185
+ return ModbusTcpWriteCommand(comm_addr, register, value)
186
+
187
+ def write_multi_command(self, comm_addr: int, offset: int, values: bytes) -> ProtocolCommand:
188
+ """Create write multiple protocol command."""
189
+ return ModbusTcpWriteMultiCommand(comm_addr, offset, values)
190
+
191
+ async def _connect(self) -> None:
192
+ if not self._transport or self._transport.is_closing():
193
+ logger.debug("Opening connection.")
194
+ self._transport, self.protocol = await asyncio.get_running_loop().create_connection(
195
+ lambda: self,
196
+ host=self._host, port=self._port,
197
+ )
198
+
199
+ def connection_made(self, transport: asyncio.DatagramTransport) -> None:
200
+ """On connection made"""
201
+ logger.debug("Connection opened.")
202
+ pass
203
+
204
+ def eof_received(self) -> None:
205
+ logger.debug("EOF received.")
206
+ self._close_transport()
207
+
208
+ def connection_lost(self, exc: Optional[Exception]) -> None:
209
+ """On connection lost"""
210
+ if exc:
211
+ logger.debug("Connection closed with error: %s.", exc)
212
+ else:
213
+ logger.debug("Connection closed.")
214
+ self._close_transport()
215
+
216
+ def data_received(self, data: bytes) -> None:
217
+ """On data received"""
218
+ if self._timer:
219
+ self._timer.cancel()
220
+ try:
221
+ if self.command.validator(data):
222
+ logger.debug("Received: %s", data.hex())
223
+ self._retry = 0
224
+ self.response_future.set_result(data)
225
+ else:
226
+ logger.debug("Received invalid response: %s", data.hex())
227
+ self.response_future.set_exception(RequestRejectedException())
228
+ self._close_transport()
229
+ except RequestRejectedException as ex:
230
+ logger.debug("Received exception response: %s", data.hex())
231
+ self.response_future.set_exception(ex)
232
+ # self._close_transport()
233
+
234
+ def error_received(self, exc: Exception) -> None:
235
+ """On error received"""
236
+ logger.debug("Received error: %s", exc)
237
+ self.response_future.set_exception(exc)
238
+ self._close_transport()
239
+
240
+ async def send_request(self, command: ProtocolCommand) -> Future:
241
+ """Send message via transport"""
242
+ await self._ensure_lock().acquire()
243
+ try:
244
+ await self._connect()
245
+ response_future = asyncio.get_running_loop().create_future()
246
+ self._send_request(command, response_future)
247
+ await response_future
248
+ return response_future
249
+ except asyncio.CancelledError:
250
+ if self._retry < self.retries:
251
+ if self._timer:
252
+ logger.debug("Connection broken error")
253
+ self._retry += 1
254
+ if self._lock and self._lock.locked():
255
+ self._lock.release()
256
+ self._close_transport()
257
+ return await self.send_request(command)
258
+ else:
259
+ return self._max_retries_reached()
260
+ except (ConnectionRefusedError, TimeoutError) as exc:
261
+ if self._retry < self.retries:
262
+ logger.debug("Connection refused error: %s", exc)
263
+ self._retry += 1
264
+ if self._lock and self._lock.locked():
265
+ self._lock.release()
266
+ return await self.send_request(command)
267
+ else:
268
+ return self._max_retries_reached()
269
+ finally:
270
+ if self._lock and self._lock.locked():
271
+ self._lock.release()
272
+
273
+ def _send_request(self, command: ProtocolCommand, response_future: Future) -> None:
274
+ """Send message via transport"""
275
+ self.command = command
276
+ self.response_future = response_future
277
+ logger.debug("Sending: %s%s", self.command,
278
+ f' - retry #{self._retry}/{self.retries}' if self._retry > 0 else '')
279
+ self._transport.write(self.command.request)
280
+ self._timer = asyncio.get_running_loop().call_later(self.timeout, self._timeout_mechanism)
281
+
282
+ def _timeout_mechanism(self) -> None:
283
+ """Retry mechanism to prevent hanging transport"""
284
+ if self.response_future.done():
285
+ self._retry = 0
286
+ else:
287
+ if self._timer:
288
+ logger.debug("Failed to receive response to %s in time (%ds).", self.command, self.timeout)
289
+ self._timer = None
290
+ self._close_transport()
291
+
292
+ def _max_retries_reached(self) -> Future:
293
+ logger.debug("Max number of retries (%d) reached, request %s failed.", self.retries, self.command)
294
+ self._close_transport()
295
+ self.response_future = asyncio.get_running_loop().create_future()
296
+ self.response_future.set_exception(MaxRetriesException)
297
+ return self.response_future
298
+
299
+ def _close_transport(self) -> None:
300
+ if self._transport:
301
+ try:
302
+ self._transport.close()
303
+ except RuntimeError:
304
+ logger.debug("Failed to close transport.")
305
+ self._transport = None
306
+ # Cancel Future on connection lost
307
+ if self.response_future and not self.response_future.done():
308
+ self.response_future.cancel()
83
309
 
84
310
 
85
311
  class ProtocolResponse:
@@ -136,22 +362,14 @@ class ProtocolCommand:
136
362
  """Calculate relative offset to start of the response bytes"""
137
363
  return address
138
364
 
139
- async def execute(self, host: str, timeout: int, retries: int) -> ProtocolResponse:
365
+ async def execute(self, protocol: InverterProtocol) -> ProtocolResponse:
140
366
  """
141
- Execute the udp protocol command on the specified address/port.
142
- Since the UDP communication is by definition unreliable, when no (valid) response is received by specified
143
- timeout, the command will be re-tried up to retries times.
367
+ Execute the protocol command on the specified connection.
144
368
 
145
- Return raw response data
369
+ Return ProtocolResponse with raw response data
146
370
  """
147
- loop = asyncio.get_running_loop()
148
- response_future = loop.create_future()
149
- transport, _ = await loop.create_datagram_endpoint(
150
- lambda: UdpInverterProtocol(response_future, self, timeout, retries),
151
- remote_addr=(host, GOODWE_UDP_PORT),
152
- )
153
371
  try:
154
- await response_future
372
+ response_future = await protocol.send_request(self)
155
373
  result = response_future.result()
156
374
  if result is not None:
157
375
  return ProtocolResponse(result, self)
@@ -159,12 +377,10 @@ class ProtocolCommand:
159
377
  raise RequestFailedException(
160
378
  "No response received to '" + self.request.hex() + "' request."
161
379
  )
162
- except asyncio.CancelledError:
380
+ except (asyncio.CancelledError, ConnectionRefusedError):
163
381
  raise RequestFailedException(
164
382
  "No valid response received to '" + self.request.hex() + "' request."
165
383
  ) from None
166
- finally:
167
- transport.close()
168
384
 
169
385
 
170
386
  class Aa55ProtocolCommand(ProtocolCommand):
@@ -257,7 +473,7 @@ class Aa55WriteMultiCommand(Aa55ProtocolCommand):
257
473
  "02B9")
258
474
 
259
475
 
260
- class ModbusProtocolCommand(ProtocolCommand):
476
+ class ModbusRtuProtocolCommand(ProtocolCommand):
261
477
  """
262
478
  Inverter communication protocol seen on newer generation of inverters, based on Modbus
263
479
  protocol over UDP transport layer.
@@ -282,7 +498,7 @@ class ModbusProtocolCommand(ProtocolCommand):
282
498
  def __init__(self, request: bytes, cmd: int, offset: int, value: int):
283
499
  super().__init__(
284
500
  request,
285
- lambda x: validate_modbus_response(x, cmd, offset, value),
501
+ lambda x: validate_modbus_rtu_response(x, cmd, offset, value),
286
502
  )
287
503
  self.first_address: int = offset
288
504
  self.value = value
@@ -296,14 +512,78 @@ class ModbusProtocolCommand(ProtocolCommand):
296
512
  return (address - self.first_address) * 2
297
513
 
298
514
 
299
- class ModbusReadCommand(ModbusProtocolCommand):
515
+ class ModbusRtuReadCommand(ModbusRtuProtocolCommand):
300
516
  """
301
- Inverter modbus READ command for retrieving <count> modbus registers starting at register # <offset>
517
+ Inverter Modbus/RTU READ command for retrieving <count> modbus registers starting at register # <offset>
518
+ """
519
+
520
+ def __init__(self, comm_addr: int, offset: int, count: int):
521
+ super().__init__(
522
+ create_modbus_rtu_request(comm_addr, MODBUS_READ_CMD, offset, count),
523
+ MODBUS_READ_CMD, offset, count)
524
+
525
+ def __repr__(self):
526
+ if self.value > 1:
527
+ return f'READ {self.value} registers from {self.first_address} ({self.request.hex()})'
528
+ else:
529
+ return f'READ register {self.first_address} ({self.request.hex()})'
530
+
531
+
532
+ class ModbusRtuWriteCommand(ModbusRtuProtocolCommand):
533
+ """
534
+ Inverter Modbus/RTU WRITE command setting single modbus register # <register> value <value>
535
+ """
536
+
537
+ def __init__(self, comm_addr: int, register: int, value: int):
538
+ super().__init__(
539
+ create_modbus_rtu_request(comm_addr, MODBUS_WRITE_CMD, register, value),
540
+ MODBUS_WRITE_CMD, register, value)
541
+
542
+ def __repr__(self):
543
+ return f'WRITE {self.value} to register {self.first_address} ({self.request.hex()})'
544
+
545
+
546
+ class ModbusRtuWriteMultiCommand(ModbusRtuProtocolCommand):
547
+ """
548
+ Inverter Modbus/RTU WRITE command setting multiple modbus register # <register> value <value>
549
+ """
550
+
551
+ def __init__(self, comm_addr: int, offset: int, values: bytes):
552
+ super().__init__(
553
+ create_modbus_rtu_multi_request(comm_addr, MODBUS_WRITE_MULTI_CMD, offset, values),
554
+ MODBUS_WRITE_MULTI_CMD, offset, len(values) // 2)
555
+
556
+
557
+ class ModbusTcpProtocolCommand(ProtocolCommand):
558
+ """
559
+ Modbus/TCP inverter communication protocol.
560
+ """
561
+
562
+ def __init__(self, request: bytes, cmd: int, offset: int, value: int):
563
+ super().__init__(
564
+ request,
565
+ lambda x: validate_modbus_tcp_response(x, cmd, offset, value),
566
+ )
567
+ self.first_address: int = offset
568
+ self.value = value
569
+
570
+ def trim_response(self, raw_response: bytes):
571
+ """Trim raw response from header and checksum data"""
572
+ return raw_response[9:]
573
+
574
+ def get_offset(self, address: int):
575
+ """Calculate relative offset to start of the response bytes"""
576
+ return (address - self.first_address) * 2
577
+
578
+
579
+ class ModbusTcpReadCommand(ModbusTcpProtocolCommand):
580
+ """
581
+ Inverter Modbus/TCP READ command for retrieving <count> modbus registers starting at register # <offset>
302
582
  """
303
583
 
304
584
  def __init__(self, comm_addr: int, offset: int, count: int):
305
585
  super().__init__(
306
- create_modbus_request(comm_addr, MODBUS_READ_CMD, offset, count),
586
+ create_modbus_tcp_request(comm_addr, MODBUS_READ_CMD, offset, count),
307
587
  MODBUS_READ_CMD, offset, count)
308
588
 
309
589
  def __repr__(self):
@@ -313,26 +593,26 @@ class ModbusReadCommand(ModbusProtocolCommand):
313
593
  return f'READ register {self.first_address} ({self.request.hex()})'
314
594
 
315
595
 
316
- class ModbusWriteCommand(ModbusProtocolCommand):
596
+ class ModbusTcpWriteCommand(ModbusTcpProtocolCommand):
317
597
  """
318
- Inverter modbus WRITE command setting single modbus register # <register> value <value>
598
+ Inverter Modbus/TCP WRITE command setting single modbus register # <register> value <value>
319
599
  """
320
600
 
321
601
  def __init__(self, comm_addr: int, register: int, value: int):
322
602
  super().__init__(
323
- create_modbus_request(comm_addr, MODBUS_WRITE_CMD, register, value),
603
+ create_modbus_tcp_request(comm_addr, MODBUS_WRITE_CMD, register, value),
324
604
  MODBUS_WRITE_CMD, register, value)
325
605
 
326
606
  def __repr__(self):
327
607
  return f'WRITE {self.value} to register {self.first_address} ({self.request.hex()})'
328
608
 
329
609
 
330
- class ModbusWriteMultiCommand(ModbusProtocolCommand):
610
+ class ModbusTcpWriteMultiCommand(ModbusTcpProtocolCommand):
331
611
  """
332
- Inverter modbus WRITE command setting multiple modbus register # <register> value <value>
612
+ Inverter Modbus/TCP WRITE command setting multiple modbus register # <register> value <value>
333
613
  """
334
614
 
335
615
  def __init__(self, comm_addr: int, offset: int, values: bytes):
336
616
  super().__init__(
337
- create_modbus_multi_request(comm_addr, MODBUS_WRITE_MULTI_CMD, offset, values),
617
+ create_modbus_tcp_multi_request(comm_addr, MODBUS_WRITE_MULTI_CMD, offset, values),
338
618
  MODBUS_WRITE_MULTI_CMD, offset, len(values) // 2)
goodwe/sensor.py CHANGED
@@ -881,12 +881,15 @@ def read_freq(buffer: ProtocolResponse, offset: int = None) -> float:
881
881
  return float(value) / 100
882
882
 
883
883
 
884
- def read_temp(buffer: ProtocolResponse, offset: int = None) -> float:
884
+ def read_temp(buffer: ProtocolResponse, offset: int = None) -> float | None:
885
885
  """Retrieve temperature [C] value (2 bytes) from buffer"""
886
886
  if offset is not None:
887
887
  buffer.seek(offset)
888
888
  value = int.from_bytes(buffer.read(2), byteorder="big", signed=True)
889
- return float(value) / 10
889
+ if value == 32767:
890
+ return None
891
+ else:
892
+ return float(value) / 10
890
893
 
891
894
 
892
895
  def read_datetime(buffer: ProtocolResponse, offset: int = None) -> datetime:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: goodwe
3
- Version: 0.3.5
3
+ Version: 0.4.0
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
@@ -32,15 +32,21 @@ License-File: LICENSE
32
32
  Library for connecting to GoodWe inverter over local network and retrieving runtime sensor values and configuration
33
33
  parameters.
34
34
 
35
- It has been reported to work on GoodWe ET, EH, BT, BH, ES, EM, BP, DT, MS, D-NS, and XS families of inverters. It may
36
- work on other inverters as well, as long as they listen on UDP port 8899 and respond to one of supported communication
37
- protocols.
35
+ It has been reported to work with GoodWe ET, EH, BT, BH, ES, EM, BP, DT, MS, D-NS, and XS families of inverters. It
36
+ should work with other inverters as well, as long as they listen on UDP port 8899 and respond to one of supported
37
+ communication protocols.
38
+ In general, if you can connect to your inverter with the official mobile app (SolarGo/PvMaster) over Wi-Fi (not
39
+ bluetooth), this library should work.
38
40
 
39
41
  (If you can't communicate with the inverter despite your model is listed above, it is possible you have old ARM firmware
40
42
  version. You should ask manufacturer support to upgrade your ARM firmware (not just inverter firmware) to be able to
41
- communicate with the inveter via UDP.)
43
+ communicate with the inverter via UDP.)
42
44
 
43
- White-label (GoodWe manufactured) inverters may work as well, e.g. General Electric GEP (PSB, PSC) and GEH models.
45
+ White-label (GoodWe manufactured) inverters may work as well, e.g. General Electric GEP (PSB, PSC) and GEH models are
46
+ know to work properly.
47
+
48
+ Since v0.4.x the library also supports standard Modbus/TCP over port 502.
49
+ This protocol is supported by the V2.0 version of LAN+WiFi communication dongle (model WLA0000-01-00P).
44
50
 
45
51
  ## Usage
46
52
 
@@ -72,4 +78,3 @@ asyncio.run(get_runtime_data())
72
78
  - https://github.com/mletenay/home-assistant-goodwe-inverter
73
79
  - https://github.com/yasko-pv/modbus-log
74
80
  - https://github.com/tkubec/GoodWe
75
- - https://github.com/OpenEMS/openems
@@ -0,0 +1,16 @@
1
+ goodwe/__init__.py,sha256=0Zwuri1cbJ2Qe24R2rEjDMTZeVtsh21YIx3KlRaXgWg,5742
2
+ goodwe/const.py,sha256=yhWk56YV7k7-MbgfmWEMYNlqeRNLOfOpfTqEfRj6Hp8,7934
3
+ goodwe/dt.py,sha256=q8PRs0nVqN4mEhH8243NTbkkBtrGx-n8icwE-BkTN5Q,10460
4
+ goodwe/es.py,sha256=gnSla5SGXK3cJag45o9Z2Wd7rwLkjm3xmS-JN1lf5Ck,22545
5
+ goodwe/et.py,sha256=VJxCl54DILBRFQTmm-9K2yqS_QbBVMDvPFNv8dr0z7Y,39676
6
+ goodwe/exceptions.py,sha256=I6PHG0GTWgxNrDVZwJZBnyzItRq5eiM6ci23-EEsn1I,1012
7
+ goodwe/inverter.py,sha256=JIKYcOLihxCG1_m7HGMoFgVR1dyO8F0OXP5q1ClQJ-w,10336
8
+ goodwe/modbus.py,sha256=sFmkBgylwJkZd64a52fOUst6Rde5Vm3JsAm3Nh3s6e8,8151
9
+ goodwe/model.py,sha256=dWBjMFJMnhZoUdDd9fGT54DERDANz4TirK0Wy8kWMbk,2068
10
+ goodwe/protocol.py,sha256=_jPwIlKE5ou2X3_3PDTUzsgBLLD1dzAdyt5DJOsWTWA,24873
11
+ goodwe/sensor.py,sha256=fWMYyr3Vw02axfGvL7y7YUH2LmfE4A_lsIulX0Zpy5c,37054
12
+ goodwe-0.4.0.dist-info/LICENSE,sha256=aZAhk3lRdYT1YZV-IKRHISEcc_KNUmgfuNO3QhRamNM,1073
13
+ goodwe-0.4.0.dist-info/METADATA,sha256=CRS4h7iSlxExGN2ZjLungFGwuI7d85g-TlVRbnPx6vU,3376
14
+ goodwe-0.4.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
15
+ goodwe-0.4.0.dist-info/top_level.txt,sha256=kKoiqiVvAxDaDJYMZZQLgHQj9cuWT1MXLfXElTDuf8s,7
16
+ goodwe-0.4.0.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- goodwe/__init__.py,sha256=PInrrZEpTmMOQKk494vIz8EKSaw_qLBNz-6t9eLIUcg,5642
2
- goodwe/const.py,sha256=Nw-nd4UJuqUOLfbmOrxTHEdS1AuaTDSpZzQqR6tBb8w,7912
3
- goodwe/dt.py,sha256=bI53MVdZjtxTYU2qJLO8icsvF6UiXrkgH95V3iUwXT0,10581
4
- goodwe/es.py,sha256=XBP7txg9d4tMsFmHWs8LB4wdJmspKVD9ALfS9mePiJk,22650
5
- goodwe/et.py,sha256=0XFwRMZeUdKZ4dhGANpw2o1EKsfgJGdGYsN95zhxV1s,40084
6
- goodwe/exceptions.py,sha256=I6PHG0GTWgxNrDVZwJZBnyzItRq5eiM6ci23-EEsn1I,1012
7
- goodwe/inverter.py,sha256=7DgIzSHimkVAfNyIkzALeukHOHkOuYjVyUIvuT0LHdE,10342
8
- goodwe/modbus.py,sha256=ZPib-zKnOVE5zc0RNnhlf0w_26QBees1ScWGo6bAj0o,4685
9
- goodwe/model.py,sha256=dWBjMFJMnhZoUdDd9fGT54DERDANz4TirK0Wy8kWMbk,2068
10
- goodwe/protocol.py,sha256=pUkXTP2DqpKXGO7rbRfHq1x82Y1QM6OiRVx8cAtS0sM,13162
11
- goodwe/sensor.py,sha256=vFbsz4Dp0yw0rBNdKqzkMUupuBJWC17YcxvBrjvFAjU,36990
12
- goodwe-0.3.5.dist-info/LICENSE,sha256=aZAhk3lRdYT1YZV-IKRHISEcc_KNUmgfuNO3QhRamNM,1073
13
- goodwe-0.3.5.dist-info/METADATA,sha256=aITPWNoquftA7VYNraUZqMoO8Bu-DXQ98ZAJxhsHtZM,3050
14
- goodwe-0.3.5.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
15
- goodwe-0.3.5.dist-info/top_level.txt,sha256=kKoiqiVvAxDaDJYMZZQLgHQj9cuWT1MXLfXElTDuf8s,7
16
- goodwe-0.3.5.dist-info/RECORD,,
File without changes