goodwe 0.4.1__py3-none-any.whl → 0.4.3__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
@@ -22,9 +22,6 @@ DT_FAMILY = ["DT", "MS", "NS", "XS"]
22
22
  # Initial discovery command
23
23
  DISCOVERY_COMMAND = Aa55ProtocolCommand("010200", "0182")
24
24
 
25
- # supported inverter protocols
26
- _SUPPORTED_PROTOCOLS = [ET, DT, ES]
27
-
28
25
 
29
26
  async def connect(host: str, port: int = GOODWE_UDP_PORT, family: str = None, comm_addr: int = 0, timeout: int = 1,
30
27
  retries: int = 3, do_discover: bool = True) -> Inverter:
@@ -41,7 +38,7 @@ async def connect(host: str, port: int = GOODWE_UDP_PORT, family: str = None, co
41
38
 
42
39
  Raise InverterError if unable to contact or recognise supported inverter.
43
40
  """
44
- if family in ET_FAMILY or port == GOODWE_TCP_PORT:
41
+ if family in ET_FAMILY:
45
42
  inv = ET(host, port, comm_addr, timeout, retries)
46
43
  elif family in ES_FAMILY:
47
44
  inv = ES(host, port, comm_addr, timeout, retries)
@@ -65,42 +62,43 @@ async def discover(host: str, port: int = GOODWE_UDP_PORT, timeout: int = 1, ret
65
62
  """
66
63
  failures = []
67
64
 
68
- # Try the common AA55C07F0102000241 command first and detect inverter type from serial_number
69
- try:
70
- logger.debug("Probing inverter at %s:%s.", host, port)
71
- response = await DISCOVERY_COMMAND.execute(UdpInverterProtocol(host, port, timeout, retries))
72
- response = response.response_data()
73
- model_name = response[5:15].decode("ascii").rstrip()
74
- serial_number = response[31:47].decode("ascii")
75
-
76
- i: Inverter | None = None
77
- for model_tag in ET_MODEL_TAGS:
78
- if model_tag in serial_number:
79
- logger.debug("Detected ET/EH/BT/BH/GEH inverter %s, S/N:%s.", model_name, serial_number)
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:
65
+ if port == GOODWE_UDP_PORT:
66
+ # Try the common AA55C07F0102000241 command first and detect inverter type from serial_number
67
+ try:
68
+ logger.debug("Probing inverter at %s:%s.", host, port)
69
+ response = await DISCOVERY_COMMAND.execute(UdpInverterProtocol(host, port, timeout, retries))
70
+ response = response.response_data()
71
+ model_name = response[5:15].decode("ascii").rstrip()
72
+ serial_number = response[31:47].decode("ascii")
73
+
74
+ i: Inverter | None = None
75
+ for model_tag in ET_MODEL_TAGS:
90
76
  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)
77
+ logger.debug("Detected ET/EH/BT/BH/GEH inverter %s, S/N:%s.", model_name, serial_number)
78
+ i = ET(host, port, 0, timeout, retries)
93
79
  break
94
- if i:
95
- await i.read_device_info()
96
- logger.debug("Connected to inverter %s, S/N:%s.", i.model_name, i.serial_number)
97
- return i
80
+ if not i:
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
+ i = ES(host, port, 0, timeout, retries)
85
+ break
86
+ if not i:
87
+ for model_tag in DT_MODEL_TAGS:
88
+ if model_tag in serial_number:
89
+ logger.debug("Detected DT/MS/D-NS/XS/GEP inverter %s, S/N:%s.", model_name, serial_number)
90
+ i = DT(host, port, 0, timeout, retries)
91
+ break
92
+ if i:
93
+ await i.read_device_info()
94
+ logger.debug("Connected to inverter %s, S/N:%s.", i.model_name, i.serial_number)
95
+ return i
98
96
 
99
- except InverterError as ex:
100
- failures.append(ex)
97
+ except InverterError as ex:
98
+ failures.append(ex)
101
99
 
102
100
  # Probe inverter specific protocols
103
- for inv in _SUPPORTED_PROTOCOLS:
101
+ for inv in [ET, DT, ES]:
104
102
  i = inv(host, port, 0, timeout, retries)
105
103
  try:
106
104
  logger.debug("Probing %s inverter at %s.", inv.__name__, host)
goodwe/dt.py CHANGED
@@ -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))
goodwe/es.py CHANGED
@@ -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:
goodwe/et.py CHANGED
@@ -457,10 +457,7 @@ class ET(Inverter):
457
457
  )
458
458
 
459
459
  def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
460
- super().__init__(host, port, comm_addr, timeout, retries)
461
- if not self.comm_addr:
462
- # Set the default inverter address
463
- self.comm_addr = 0xf7
460
+ super().__init__(host, port, comm_addr if comm_addr else 0xf7, timeout, retries)
464
461
  self._READ_DEVICE_VERSION_INFO: ProtocolCommand = self._read_command(0x88b8, 0x0021)
465
462
  self._READ_RUNNING_DATA: ProtocolCommand = self._read_command(0x891c, 0x007d)
466
463
  self._READ_METER_DATA: ProtocolCommand = self._read_command(0x8ca0, 0x2d)
@@ -611,26 +608,35 @@ class ET(Inverter):
611
608
 
612
609
  async def read_setting(self, setting_id: str) -> Any:
613
610
  setting = self._settings.get(setting_id)
614
- if not setting:
615
- raise ValueError(f'Unknown setting "{setting_id}"')
616
- try:
611
+ if setting:
617
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}"')
619
+
620
+ async def _read_setting(self, setting: Sensor) -> Any:
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)
618
625
  except RequestRejectedException as ex:
619
626
  if ex.message == ILLEGAL_DATA_ADDRESS:
620
627
  logger.debug("Unsupported setting %s", setting.id_)
621
- self._settings.pop(setting_id, None)
628
+ self._settings.pop(setting.id_, None)
622
629
  return None
623
630
 
624
- async def _read_setting(self, setting: Sensor) -> Any:
625
- count = (setting.size_ + (setting.size_ % 2)) // 2
626
- response = await self._read_from_socket(self._read_command(setting.offset, count))
627
- return setting.read_value(response)
628
-
629
631
  async def write_setting(self, setting_id: str, value: Any):
630
632
  setting = self._settings.get(setting_id)
631
- if not setting:
632
- raise ValueError(f'Unknown setting "{setting_id}"')
633
- 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}"')
634
640
 
635
641
  async def _write_setting(self, setting: Sensor, value: Any):
636
642
  if setting.size_ == 1:
goodwe/exceptions.py CHANGED
@@ -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"""
goodwe/inverter.py CHANGED
@@ -89,11 +89,9 @@ class Inverter(ABC):
89
89
  """
90
90
 
91
91
  def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
92
- self._protocol: InverterProtocol = self._create_protocol(host, port, timeout, retries)
92
+ self._protocol: InverterProtocol = self._create_protocol(host, port, comm_addr, timeout, retries)
93
93
  self._consecutive_failures_count: int = 0
94
94
 
95
- self.comm_addr: int = comm_addr
96
-
97
95
  self.model_name: str | None = None
98
96
  self.serial_number: str | None = None
99
97
  self.rated_power: int = 0
@@ -109,15 +107,15 @@ class Inverter(ABC):
109
107
 
110
108
  def _read_command(self, offset: int, count: int) -> ProtocolCommand:
111
109
  """Create read protocol command."""
112
- return self._protocol.read_command(self.comm_addr, offset, count)
110
+ return self._protocol.read_command(offset, count)
113
111
 
114
112
  def _write_command(self, register: int, value: int) -> ProtocolCommand:
115
113
  """Create write protocol command."""
116
- return self._protocol.write_command(self.comm_addr, register, value)
114
+ return self._protocol.write_command(register, value)
117
115
 
118
116
  def _write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
119
117
  """Create write multiple protocol command."""
120
- return self._protocol.write_multi_command(self.comm_addr, offset, values)
118
+ return self._protocol.write_multi_command(offset, values)
121
119
 
122
120
  async def _read_from_socket(self, command: ProtocolCommand) -> ProtocolResponse:
123
121
  try:
@@ -132,6 +130,9 @@ class Inverter(ABC):
132
130
  self._consecutive_failures_count += 1
133
131
  raise RequestFailedException(ex.message, self._consecutive_failures_count) from None
134
132
 
133
+ def set_keep_alive(self, keep_alive: bool) -> None:
134
+ self._protocol.keep_alive = keep_alive
135
+
135
136
  @abstractmethod
136
137
  async def read_device_info(self):
137
138
  """
@@ -270,11 +271,11 @@ class Inverter(ABC):
270
271
  raise NotImplementedError()
271
272
 
272
273
  @staticmethod
273
- def _create_protocol(host: str, port: int, timeout: int, retries: int) -> InverterProtocol:
274
+ def _create_protocol(host: str, port: int, comm_addr: int, timeout: int, retries: int) -> InverterProtocol:
274
275
  if port == 502:
275
- return TcpInverterProtocol(host, port, timeout, retries)
276
+ return TcpInverterProtocol(host, port, comm_addr, timeout, retries)
276
277
  else:
277
- return UdpInverterProtocol(host, port, timeout, retries)
278
+ return UdpInverterProtocol(host, port, comm_addr, timeout, retries)
278
279
 
279
280
  @staticmethod
280
281
  def _map_response(response: ProtocolResponse, sensors: Tuple[Sensor, ...]) -> Dict[str, Any]:
goodwe/modbus.py CHANGED
@@ -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,7 +9,7 @@ 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 = 'ILLEGAL DATA ADDRESS'
12
+ ILLEGAL_DATA_ADDRESS: str = 'ILLEGAL DATA ADDRESS'
13
13
 
14
14
  FAILURE_CODES = {
15
15
  1: "ILLEGAL FUNCTION",
@@ -178,8 +178,7 @@ def validate_modbus_rtu_response(data: bytes, cmd: int, offset: int, value: int)
178
178
  return False
179
179
  expected_length = data[4] + 7
180
180
  if len(data) < expected_length:
181
- logger.debug("Response is too short: %d, expected %d.", len(data), expected_length)
182
- return False
181
+ raise PartialResponseException(len(data), expected_length)
183
182
  elif data[3] in (MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD):
184
183
  if len(data) < 10:
185
184
  logger.debug("Response has unexpected length: %d, expected %d.", len(data), 10)
@@ -222,18 +221,22 @@ def validate_modbus_tcp_response(data: bytes, cmd: int, offset: int, value: int)
222
221
  if len(data) <= 8:
223
222
  logger.debug("Response is too short.")
224
223
  return False
224
+ expected_length = int.from_bytes(data[4:6], byteorder='big', signed=False) + 6
225
+ # The weird expected_length != 12 is work around Goodwe bug answering wrong (hardcoded 6) length.
226
+ if len(data) < expected_length and expected_length != 12:
227
+ raise PartialResponseException(len(data), expected_length)
228
+
225
229
  if data[7] == MODBUS_READ_CMD:
226
- if data[8] != value * 2:
227
- logger.debug("Response has unexpected length: %d, expected %d.", data[8], value * 2)
228
- return False
229
230
  expected_length = data[8] + 9
230
231
  if len(data) < expected_length:
231
- logger.debug("Response is too short: %d, expected %d.", len(data), expected_length)
232
+ raise PartialResponseException(len(data), expected_length)
233
+ if data[8] != value * 2:
234
+ logger.debug("Response has unexpected length: %d, expected %d.", data[8], value * 2)
232
235
  return False
233
236
  elif data[7] in (MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD):
234
237
  if len(data) < 12:
235
238
  logger.debug("Response has unexpected length: %d, expected %d.", len(data), 14)
236
- return False
239
+ raise PartialResponseException(len(data), expected_length)
237
240
  response_offset = int.from_bytes(data[8:10], byteorder='big', signed=False)
238
241
  if response_offset != offset:
239
242
  logger.debug("Response has wrong offset: %X, expected %X.", response_offset, offset)
goodwe/protocol.py CHANGED
@@ -3,30 +3,45 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import io
5
5
  import logging
6
+ import platform
7
+ import socket
6
8
  from asyncio.futures import Future
7
9
  from typing import Tuple, Optional, Callable
8
10
 
9
- from .exceptions import MaxRetriesException, RequestFailedException, RequestRejectedException
11
+ from .exceptions import MaxRetriesException, PartialResponseException, RequestFailedException, RequestRejectedException
10
12
  from .modbus import create_modbus_rtu_request, create_modbus_rtu_multi_request, create_modbus_tcp_request, \
11
13
  create_modbus_tcp_multi_request, validate_modbus_rtu_response, validate_modbus_tcp_response, MODBUS_READ_CMD, \
12
14
  MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD
13
15
 
14
16
  logger = logging.getLogger(__name__)
15
17
 
18
+ _modbus_tcp_tx = 0
19
+
20
+
21
+ def _next_tx() -> bytes:
22
+ global _modbus_tcp_tx
23
+ _modbus_tcp_tx += 1
24
+ if _modbus_tcp_tx == 0xFFFF:
25
+ _modbus_tcp_tx = 1
26
+ return int.to_bytes(_modbus_tcp_tx, length=2, byteorder="big", signed=False)
27
+
16
28
 
17
29
  class InverterProtocol:
18
30
 
19
- def __init__(self, host: str, port: int, timeout: int, retries: int):
31
+ def __init__(self, host: str, port: int, comm_addr: int, timeout: int, retries: int):
20
32
  self._host: str = host
21
33
  self._port: int = port
34
+ self._comm_addr: int = comm_addr
22
35
  self._running_loop: asyncio.AbstractEventLoop | None = None
23
36
  self._lock: asyncio.Lock | None = None
24
37
  self._timer: asyncio.TimerHandle | None = None
25
38
  self.timeout: int = timeout
26
39
  self.retries: int = retries
40
+ self.keep_alive: bool = True
27
41
  self.protocol: asyncio.Protocol | None = None
28
42
  self.response_future: Future | None = None
29
43
  self.command: ProtocolCommand | None = None
44
+ self._partial_data: bytes | None = None
30
45
 
31
46
  def _ensure_lock(self) -> asyncio.Lock:
32
47
  """Validate (or create) asyncio Lock.
@@ -46,42 +61,44 @@ class InverterProtocol:
46
61
  self._close_transport()
47
62
  return self._lock
48
63
 
49
- def _close_transport(self) -> None:
64
+ async def close(self) -> None:
65
+ """Close the underlying transport/connection."""
50
66
  raise NotImplementedError()
51
67
 
52
68
  async def send_request(self, command: ProtocolCommand) -> Future:
69
+ """Convert command to request and send it to inverter."""
53
70
  raise NotImplementedError()
54
71
 
55
- def read_command(self, comm_addr: int, offset: int, count: int) -> ProtocolCommand:
72
+ def read_command(self, offset: int, count: int) -> ProtocolCommand:
56
73
  """Create read protocol command."""
57
74
  raise NotImplementedError()
58
75
 
59
- def write_command(self, comm_addr: int, register: int, value: int) -> ProtocolCommand:
76
+ def write_command(self, register: int, value: int) -> ProtocolCommand:
60
77
  """Create write protocol command."""
61
78
  raise NotImplementedError()
62
79
 
63
- def write_multi_command(self, comm_addr: int, offset: int, values: bytes) -> ProtocolCommand:
80
+ def write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
64
81
  """Create write multiple protocol command."""
65
82
  raise NotImplementedError()
66
83
 
67
84
 
68
85
  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)
86
+ def __init__(self, host: str, port: int, comm_addr: int, timeout: int = 1, retries: int = 3):
87
+ super().__init__(host, port, comm_addr, timeout, retries)
71
88
  self._transport: asyncio.transports.DatagramTransport | None = None
72
89
  self._retry: int = 0
73
90
 
74
- def read_command(self, comm_addr: int, offset: int, count: int) -> ProtocolCommand:
91
+ def read_command(self, offset: int, count: int) -> ProtocolCommand:
75
92
  """Create read protocol command."""
76
- return ModbusRtuReadCommand(comm_addr, offset, count)
93
+ return ModbusRtuReadCommand(self._comm_addr, offset, count)
77
94
 
78
- def write_command(self, comm_addr: int, register: int, value: int) -> ProtocolCommand:
95
+ def write_command(self, register: int, value: int) -> ProtocolCommand:
79
96
  """Create write protocol command."""
80
- return ModbusRtuWriteCommand(comm_addr, register, value)
97
+ return ModbusRtuWriteCommand(self._comm_addr, register, value)
81
98
 
82
- def write_multi_command(self, comm_addr: int, offset: int, values: bytes) -> ProtocolCommand:
99
+ def write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
83
100
  """Create write multiple protocol command."""
84
- return ModbusRtuWriteMultiCommand(comm_addr, offset, values)
101
+ return ModbusRtuWriteMultiCommand(self._comm_addr, offset, values)
85
102
 
86
103
  async def _connect(self) -> None:
87
104
  if not self._transport or self._transport.is_closing():
@@ -108,12 +125,25 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
108
125
  self._timer.cancel()
109
126
  self._timer = None
110
127
  try:
128
+ if self._partial_data:
129
+ logger.debug("Received another response fragment: %s.", data.hex())
130
+ data = self._partial_data + data
111
131
  if self.command.validator(data):
112
- logger.debug("Received: %s", data.hex())
132
+ if self._partial_data:
133
+ logger.debug("Composed fragmented response: %s", data.hex())
134
+ else:
135
+ logger.debug("Received: %s", data.hex())
136
+ self._partial_data = None
113
137
  self.response_future.set_result(data)
114
138
  else:
115
139
  logger.debug("Received invalid response: %s", data.hex())
116
140
  asyncio.get_running_loop().call_soon(self._retry_mechanism)
141
+ except PartialResponseException:
142
+ logger.debug("Received response fragment: %s", data.hex())
143
+ self._partial_data = data
144
+ return
145
+ except asyncio.InvalidStateError:
146
+ logger.debug("Response already handled: %s", data.hex())
117
147
  except RequestRejectedException as ex:
118
148
  logger.debug("Received exception response: %s", data.hex())
119
149
  self.response_future.set_exception(ex)
@@ -139,9 +169,12 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
139
169
  """Send message via transport"""
140
170
  self.command = command
141
171
  self.response_future = response_future
142
- logger.debug("Sending: %s%s", self.command,
143
- f' - retry #{self._retry}/{self.retries}' if self._retry > 0 else '')
144
- self._transport.sendto(self.command.request)
172
+ payload = command.request_bytes()
173
+ if self._retry > 0:
174
+ logger.debug("Sending: %s - retry #%s/%s", self.command, self._retry, self.retries)
175
+ else:
176
+ logger.debug("Sending: %s", self.command)
177
+ self._transport.sendto(payload)
145
178
  self._timer = asyncio.get_running_loop().call_later(self.timeout, self._retry_mechanism)
146
179
 
147
180
  def _retry_mechanism(self) -> None:
@@ -169,24 +202,27 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
169
202
  if self.response_future and not self.response_future.done():
170
203
  self.response_future.cancel()
171
204
 
205
+ async def close(self):
206
+ self._close_transport()
207
+
172
208
 
173
209
  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)
210
+ def __init__(self, host: str, port: int, comm_addr: int, timeout: int = 1, retries: int = 0):
211
+ super().__init__(host, port, comm_addr, timeout, retries)
176
212
  self._transport: asyncio.transports.Transport | None = None
177
213
  self._retry: int = 0
178
214
 
179
- def read_command(self, comm_addr: int, offset: int, count: int) -> ProtocolCommand:
215
+ def read_command(self, offset: int, count: int) -> ProtocolCommand:
180
216
  """Create read protocol command."""
181
- return ModbusTcpReadCommand(comm_addr, offset, count)
217
+ return ModbusTcpReadCommand(self._comm_addr, offset, count)
182
218
 
183
- def write_command(self, comm_addr: int, register: int, value: int) -> ProtocolCommand:
219
+ def write_command(self, register: int, value: int) -> ProtocolCommand:
184
220
  """Create write protocol command."""
185
- return ModbusTcpWriteCommand(comm_addr, register, value)
221
+ return ModbusTcpWriteCommand(self._comm_addr, register, value)
186
222
 
187
- def write_multi_command(self, comm_addr: int, offset: int, values: bytes) -> ProtocolCommand:
223
+ def write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
188
224
  """Create write multiple protocol command."""
189
- return ModbusTcpWriteMultiCommand(comm_addr, offset, values)
225
+ return ModbusTcpWriteMultiCommand(self._comm_addr, offset, values)
190
226
 
191
227
  async def _connect(self) -> None:
192
228
  if not self._transport or self._transport.is_closing():
@@ -195,6 +231,18 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
195
231
  lambda: self,
196
232
  host=self._host, port=self._port,
197
233
  )
234
+ if self.keep_alive:
235
+ try:
236
+ sock = self._transport.get_extra_info('socket')
237
+ if sock is not None:
238
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
239
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10)
240
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
241
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
242
+ if platform.system() == 'Windows':
243
+ sock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, 10000, 10000))
244
+ except AttributeError as ex:
245
+ logger.debug("Failed to apply KEEPALIVE: %s", ex)
198
246
 
199
247
  def connection_made(self, transport: asyncio.DatagramTransport) -> None:
200
248
  """On connection made"""
@@ -218,14 +266,27 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
218
266
  if self._timer:
219
267
  self._timer.cancel()
220
268
  try:
269
+ if self._partial_data:
270
+ logger.debug("Received another response fragment: %s.", data.hex())
271
+ data = self._partial_data + data
221
272
  if self.command.validator(data):
222
- logger.debug("Received: %s", data.hex())
273
+ if self._partial_data:
274
+ logger.debug("Composed fragmented response: %s", data.hex())
275
+ else:
276
+ logger.debug("Received: %s", data.hex())
223
277
  self._retry = 0
278
+ self._partial_data = None
224
279
  self.response_future.set_result(data)
225
280
  else:
226
281
  logger.debug("Received invalid response: %s", data.hex())
227
282
  self.response_future.set_exception(RequestRejectedException())
228
283
  self._close_transport()
284
+ except PartialResponseException:
285
+ logger.debug("Received response fragment: %s", data.hex())
286
+ self._partial_data = data
287
+ return
288
+ except asyncio.InvalidStateError:
289
+ logger.debug("Response already handled: %s", data.hex())
229
290
  except RequestRejectedException as ex:
230
291
  logger.debug("Received exception response: %s", data.hex())
231
292
  self.response_future.set_exception(ex)
@@ -257,7 +318,7 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
257
318
  return await self.send_request(command)
258
319
  else:
259
320
  return self._max_retries_reached()
260
- except (ConnectionRefusedError, TimeoutError, OSError, asyncio.TimeoutError) as exc:
321
+ except (ConnectionRefusedError, TimeoutError, OSError, asyncio.TimeoutError):
261
322
  if self._retry < self.retries:
262
323
  logger.debug("Connection refused error.")
263
324
  self._retry += 1
@@ -274,9 +335,12 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
274
335
  """Send message via transport"""
275
336
  self.command = command
276
337
  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)
338
+ payload = command.request_bytes()
339
+ if self._retry > 0:
340
+ logger.debug("Sending: %s - retry #%s/%s", self.command, self._retry, self.retries)
341
+ else:
342
+ logger.debug("Sending: %s", self.command)
343
+ self._transport.write(payload)
280
344
  self._timer = asyncio.get_running_loop().call_later(self.timeout, self._timeout_mechanism)
281
345
 
282
346
  def _timeout_mechanism(self) -> None:
@@ -307,6 +371,14 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
307
371
  if self.response_future and not self.response_future.done():
308
372
  self.response_future.cancel()
309
373
 
374
+ async def close(self):
375
+ await self._ensure_lock().acquire()
376
+ try:
377
+ self._close_transport()
378
+ finally:
379
+ if self._lock and self._lock.locked():
380
+ self._lock.release()
381
+
310
382
 
311
383
  class ProtocolResponse:
312
384
  """Definition of response to protocol command"""
@@ -354,6 +426,10 @@ class ProtocolCommand:
354
426
  def __repr__(self):
355
427
  return self.request.hex()
356
428
 
429
+ def request_bytes(self) -> bytes:
430
+ """Return raw bytes payload, optionally pre-processed"""
431
+ return self.request
432
+
357
433
  def trim_response(self, raw_response: bytes):
358
434
  """Trim raw response from header and checksum data"""
359
435
  return raw_response
@@ -381,6 +457,9 @@ class ProtocolCommand:
381
457
  raise RequestFailedException(
382
458
  "No valid response received to '" + self.request.hex() + "' request."
383
459
  ) from None
460
+ finally:
461
+ if not protocol.keep_alive:
462
+ await protocol.close()
384
463
 
385
464
 
386
465
  class Aa55ProtocolCommand(ProtocolCommand):
@@ -425,8 +504,7 @@ class Aa55ProtocolCommand(ProtocolCommand):
425
504
  data[-2:] is checksum (plain sum of response data incl. header)
426
505
  """
427
506
  if len(data) <= 8 or len(data) != data[6] + 9:
428
- logger.debug("Response has unexpected length: %d, expected %d.", len(data), data[6] + 9)
429
- return False
507
+ raise PartialResponseException(len(data), data[6] + 9)
430
508
  elif response_type:
431
509
  data_rt_int = int.from_bytes(data[4:6], byteorder="big", signed=True)
432
510
  if int(response_type, 16) != data_rt_int:
@@ -567,6 +645,12 @@ class ModbusTcpProtocolCommand(ProtocolCommand):
567
645
  self.first_address: int = offset
568
646
  self.value = value
569
647
 
648
+ def request_bytes(self) -> bytes:
649
+ """Return raw bytes payload, optionally pre-processed"""
650
+ # Apply sequential Modbus/TCP transaction identifier
651
+ self.request = _next_tx() + self.request[2:]
652
+ return self.request
653
+
570
654
  def trim_response(self, raw_response: bytes):
571
655
  """Trim raw response from header and checksum data"""
572
656
  return raw_response[9:]
goodwe/sensor.py CHANGED
@@ -183,7 +183,7 @@ class Energy(Sensor):
183
183
 
184
184
  def read_value(self, data: ProtocolResponse):
185
185
  value = read_bytes2(data)
186
- return float(value) / 10 if value else None
186
+ return float(value) / 10 if value is not None else None
187
187
 
188
188
 
189
189
  class Energy4(Sensor):
@@ -194,7 +194,7 @@ class Energy4(Sensor):
194
194
 
195
195
  def read_value(self, data: ProtocolResponse):
196
196
  value = read_bytes4(data)
197
- return float(value) / 10 if value else None
197
+ return float(value) / 10 if value is not None else None
198
198
 
199
199
 
200
200
  class Apparent(Sensor):
@@ -910,7 +910,7 @@ def read_temp(buffer: ProtocolResponse, offset: int = None) -> float | None:
910
910
  if offset is not None:
911
911
  buffer.seek(offset)
912
912
  value = int.from_bytes(buffer.read(2), byteorder="big", signed=True)
913
- if value == 32767:
913
+ if value == -1 or value == 32767:
914
914
  return None
915
915
  else:
916
916
  return float(value) / 10
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: goodwe
3
- Version: 0.4.1
3
+ Version: 0.4.3
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
@@ -0,0 +1,16 @@
1
+ goodwe/__init__.py,sha256=8fFGBBvBpCo6Ew4puTtW0kYo2hVPKUx6z5A-TA4Tbvc,5795
2
+ goodwe/const.py,sha256=yhWk56YV7k7-MbgfmWEMYNlqeRNLOfOpfTqEfRj6Hp8,7934
3
+ goodwe/dt.py,sha256=oGbkdVHP51KnlwQraKeebmiP6AtJ1S67aLB7euNRIoE,11743
4
+ goodwe/es.py,sha256=iVK8EMCaAJJFihZLntJZ_Eu4sQWoZTVtTROp9mHFG6o,22730
5
+ goodwe/et.py,sha256=CiX-PE7wouDnj1RnPnOyqiNE4FELhOGdyPUOm9VCzUw,43890
6
+ goodwe/exceptions.py,sha256=dKMLxotjoR1ic8OVlw1joIJ4mKWD6oFtUMZ86fNM5ZE,1403
7
+ goodwe/inverter.py,sha256=86aMJzJjNOr1I_tCF5H6mBwzDTjLbGDKUL2hbi0XSxg,10459
8
+ goodwe/modbus.py,sha256=qDFs8pMOtwgHPfwiZLd-P34vCLHc71-b8MQZMb8FJME,8488
9
+ goodwe/model.py,sha256=dWBjMFJMnhZoUdDd9fGT54DERDANz4TirK0Wy8kWMbk,2068
10
+ goodwe/protocol.py,sha256=JhWYzUtCwbhxXCfZMA_hPGGCHcEEhn0y9B4goJ2GyNo,28306
11
+ goodwe/sensor.py,sha256=buPG8BcgZmRDqaMrLQUACLHB85U134qG6qo_ggsu48A,37679
12
+ goodwe-0.4.3.dist-info/LICENSE,sha256=aZAhk3lRdYT1YZV-IKRHISEcc_KNUmgfuNO3QhRamNM,1073
13
+ goodwe-0.4.3.dist-info/METADATA,sha256=m3IqLUDf7Ae-ElSqvqJTNFmUu-ZaQWr1YWmjz7eqJaw,3376
14
+ goodwe-0.4.3.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
15
+ goodwe-0.4.3.dist-info/top_level.txt,sha256=kKoiqiVvAxDaDJYMZZQLgHQj9cuWT1MXLfXElTDuf8s,7
16
+ goodwe-0.4.3.dist-info/RECORD,,
@@ -1,16 +0,0 @@
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=qqC-1r_Q2gmWSYEHhqdrXRCyBLjihCoWbTmbtSGtLJs,43517
6
- goodwe/exceptions.py,sha256=I6PHG0GTWgxNrDVZwJZBnyzItRq5eiM6ci23-EEsn1I,1012
7
- goodwe/inverter.py,sha256=3whUY_ZG7A8aDH1HDSgAzgtFqLOEJBvaxkDwSqKYeuM,10395
8
- goodwe/modbus.py,sha256=NUlG_d3usiJFjTRNpp61u23CLVSx8NfRJLWP4DmxpMU,8196
9
- goodwe/model.py,sha256=dWBjMFJMnhZoUdDd9fGT54DERDANz4TirK0Wy8kWMbk,2068
10
- goodwe/protocol.py,sha256=vVDLDlnjOEMkfdz8_h1bupU4K8VUxyPbrzfNytUK5us,24926
11
- goodwe/sensor.py,sha256=9Oo74Qp9vHmV8trfTn3PlGJrp0Ql1vk0U81oCmod-1c,37640
12
- goodwe-0.4.1.dist-info/LICENSE,sha256=aZAhk3lRdYT1YZV-IKRHISEcc_KNUmgfuNO3QhRamNM,1073
13
- goodwe-0.4.1.dist-info/METADATA,sha256=QpFz1_icH-w7_ZLAxUT3Ee927v_9tUJqbuNqnbsYiPQ,3376
14
- goodwe-0.4.1.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
15
- goodwe-0.4.1.dist-info/top_level.txt,sha256=kKoiqiVvAxDaDJYMZZQLgHQj9cuWT1MXLfXElTDuf8s,7
16
- goodwe-0.4.1.dist-info/RECORD,,
File without changes