goodwe 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {goodwe-0.4.2/goodwe.egg-info → goodwe-0.4.3}/PKG-INFO +1 -1
  2. goodwe-0.4.3/VERSION +1 -0
  3. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/__init__.py +33 -35
  4. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/inverter.py +3 -4
  5. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/modbus.py +5 -1
  6. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/protocol.py +44 -26
  7. {goodwe-0.4.2 → goodwe-0.4.3/goodwe.egg-info}/PKG-INFO +1 -1
  8. {goodwe-0.4.2 → goodwe-0.4.3}/tests/test_modbus.py +2 -0
  9. goodwe-0.4.2/VERSION +0 -1
  10. {goodwe-0.4.2 → goodwe-0.4.3}/LICENSE +0 -0
  11. {goodwe-0.4.2 → goodwe-0.4.3}/README.md +0 -0
  12. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/const.py +0 -0
  13. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/dt.py +0 -0
  14. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/es.py +0 -0
  15. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/et.py +0 -0
  16. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/exceptions.py +0 -0
  17. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/model.py +0 -0
  18. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/sensor.py +0 -0
  19. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe.egg-info/SOURCES.txt +0 -0
  20. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe.egg-info/dependency_links.txt +0 -0
  21. {goodwe-0.4.2 → goodwe-0.4.3}/goodwe.egg-info/top_level.txt +0 -0
  22. {goodwe-0.4.2 → goodwe-0.4.3}/pyproject.toml +0 -0
  23. {goodwe-0.4.2 → goodwe-0.4.3}/setup.cfg +0 -0
  24. {goodwe-0.4.2 → goodwe-0.4.3}/tests/test_dt.py +0 -0
  25. {goodwe-0.4.2 → goodwe-0.4.3}/tests/test_es.py +0 -0
  26. {goodwe-0.4.2 → goodwe-0.4.3}/tests/test_et.py +0 -0
  27. {goodwe-0.4.2 → goodwe-0.4.3}/tests/test_protocol.py +0 -0
  28. {goodwe-0.4.2 → goodwe-0.4.3}/tests/test_sensor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: goodwe
3
- Version: 0.4.2
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
goodwe-0.4.3/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.4.3
@@ -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)
@@ -91,7 +91,6 @@ class Inverter(ABC):
91
91
  def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
92
92
  self._protocol: InverterProtocol = self._create_protocol(host, port, comm_addr, timeout, retries)
93
93
  self._consecutive_failures_count: int = 0
94
- self.keep_alive: bool = True
95
94
 
96
95
  self.model_name: str | None = None
97
96
  self.serial_number: str | None = None
@@ -130,9 +129,9 @@ class Inverter(ABC):
130
129
  except RequestFailedException as ex:
131
130
  self._consecutive_failures_count += 1
132
131
  raise RequestFailedException(ex.message, self._consecutive_failures_count) from None
133
- finally:
134
- if not self.keep_alive:
135
- self._protocol.close_transport()
132
+
133
+ def set_keep_alive(self, keep_alive: bool) -> None:
134
+ self._protocol.keep_alive = keep_alive
136
135
 
137
136
  @abstractmethod
138
137
  async def read_device_info(self):
@@ -222,10 +222,14 @@ def validate_modbus_tcp_response(data: bytes, cmd: int, offset: int, value: int)
222
222
  logger.debug("Response is too short.")
223
223
  return False
224
224
  expected_length = int.from_bytes(data[4:6], byteorder='big', signed=False) + 6
225
- if len(data) < expected_length:
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:
226
227
  raise PartialResponseException(len(data), expected_length)
227
228
 
228
229
  if data[7] == MODBUS_READ_CMD:
230
+ expected_length = data[8] + 9
231
+ if len(data) < expected_length:
232
+ raise PartialResponseException(len(data), expected_length)
229
233
  if data[8] != value * 2:
230
234
  logger.debug("Response has unexpected length: %d, expected %d.", data[8], value * 2)
231
235
  return False
@@ -37,6 +37,7 @@ class InverterProtocol:
37
37
  self._timer: asyncio.TimerHandle | None = None
38
38
  self.timeout: int = timeout
39
39
  self.retries: int = retries
40
+ self.keep_alive: bool = True
40
41
  self.protocol: asyncio.Protocol | None = None
41
42
  self.response_future: Future | None = None
42
43
  self.command: ProtocolCommand | None = None
@@ -57,10 +58,10 @@ class InverterProtocol:
57
58
  logger.debug("Creating lock instance for current event loop.")
58
59
  self._lock = asyncio.Lock()
59
60
  self._running_loop = asyncio.get_event_loop()
60
- self.close_transport()
61
+ self._close_transport()
61
62
  return self._lock
62
63
 
63
- def close_transport(self) -> None:
64
+ async def close(self) -> None:
64
65
  """Close the underlying transport/connection."""
65
66
  raise NotImplementedError()
66
67
 
@@ -116,7 +117,7 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
116
117
  logger.debug("Socket closed with error: %s.", exc)
117
118
  else:
118
119
  logger.debug("Socket closed.")
119
- self.close_transport()
120
+ self._close_transport()
120
121
 
121
122
  def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None:
122
123
  """On datagram received"""
@@ -146,13 +147,13 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
146
147
  except RequestRejectedException as ex:
147
148
  logger.debug("Received exception response: %s", data.hex())
148
149
  self.response_future.set_exception(ex)
149
- self.close_transport()
150
+ self._close_transport()
150
151
 
151
152
  def error_received(self, exc: Exception) -> None:
152
153
  """On error received"""
153
154
  logger.debug("Received error: %s", exc)
154
155
  self.response_future.set_exception(exc)
155
- self.close_transport()
156
+ self._close_transport()
156
157
 
157
158
  async def send_request(self, command: ProtocolCommand) -> Future:
158
159
  """Send message via transport"""
@@ -188,9 +189,9 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
188
189
  else:
189
190
  logger.debug("Max number of retries (%d) reached, request %s failed.", self.retries, self.command)
190
191
  self.response_future.set_exception(MaxRetriesException)
191
- self.close_transport()
192
+ self._close_transport()
192
193
 
193
- def close_transport(self) -> None:
194
+ def _close_transport(self) -> None:
194
195
  if self._transport:
195
196
  try:
196
197
  self._transport.close()
@@ -201,6 +202,9 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
201
202
  if self.response_future and not self.response_future.done():
202
203
  self.response_future.cancel()
203
204
 
205
+ async def close(self):
206
+ self._close_transport()
207
+
204
208
 
205
209
  class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
206
210
  def __init__(self, host: str, port: int, comm_addr: int, timeout: int = 1, retries: int = 0):
@@ -227,14 +231,18 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
227
231
  lambda: self,
228
232
  host=self._host, port=self._port,
229
233
  )
230
- sock = self._transport.get_extra_info('socket')
231
- if sock is not None:
232
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
233
- sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10)
234
- sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
235
- sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
236
- if platform.system() == 'Windows':
237
- sock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, 10000, 10000))
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)
238
246
 
239
247
  def connection_made(self, transport: asyncio.DatagramTransport) -> None:
240
248
  """On connection made"""
@@ -243,7 +251,7 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
243
251
 
244
252
  def eof_received(self) -> None:
245
253
  logger.debug("EOF received.")
246
- self.close_transport()
254
+ self._close_transport()
247
255
 
248
256
  def connection_lost(self, exc: Optional[Exception]) -> None:
249
257
  """On connection lost"""
@@ -251,7 +259,7 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
251
259
  logger.debug("Connection closed with error: %s.", exc)
252
260
  else:
253
261
  logger.debug("Connection closed.")
254
- self.close_transport()
262
+ self._close_transport()
255
263
 
256
264
  def data_received(self, data: bytes) -> None:
257
265
  """On data received"""
@@ -272,7 +280,7 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
272
280
  else:
273
281
  logger.debug("Received invalid response: %s", data.hex())
274
282
  self.response_future.set_exception(RequestRejectedException())
275
- self.close_transport()
283
+ self._close_transport()
276
284
  except PartialResponseException:
277
285
  logger.debug("Received response fragment: %s", data.hex())
278
286
  self._partial_data = data
@@ -282,13 +290,13 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
282
290
  except RequestRejectedException as ex:
283
291
  logger.debug("Received exception response: %s", data.hex())
284
292
  self.response_future.set_exception(ex)
285
- # self.close_transport()
293
+ # self._close_transport()
286
294
 
287
295
  def error_received(self, exc: Exception) -> None:
288
296
  """On error received"""
289
297
  logger.debug("Received error: %s", exc)
290
298
  self.response_future.set_exception(exc)
291
- self.close_transport()
299
+ self._close_transport()
292
300
 
293
301
  async def send_request(self, command: ProtocolCommand) -> Future:
294
302
  """Send message via transport"""
@@ -306,7 +314,7 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
306
314
  self._retry += 1
307
315
  if self._lock and self._lock.locked():
308
316
  self._lock.release()
309
- self.close_transport()
317
+ self._close_transport()
310
318
  return await self.send_request(command)
311
319
  else:
312
320
  return self._max_retries_reached()
@@ -343,16 +351,16 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
343
351
  if self._timer:
344
352
  logger.debug("Failed to receive response to %s in time (%ds).", self.command, self.timeout)
345
353
  self._timer = None
346
- self.close_transport()
354
+ self._close_transport()
347
355
 
348
356
  def _max_retries_reached(self) -> Future:
349
357
  logger.debug("Max number of retries (%d) reached, request %s failed.", self.retries, self.command)
350
- self.close_transport()
358
+ self._close_transport()
351
359
  self.response_future = asyncio.get_running_loop().create_future()
352
360
  self.response_future.set_exception(MaxRetriesException)
353
361
  return self.response_future
354
362
 
355
- def close_transport(self) -> None:
363
+ def _close_transport(self) -> None:
356
364
  if self._transport:
357
365
  try:
358
366
  self._transport.close()
@@ -363,6 +371,14 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
363
371
  if self.response_future and not self.response_future.done():
364
372
  self.response_future.cancel()
365
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
+
366
382
 
367
383
  class ProtocolResponse:
368
384
  """Definition of response to protocol command"""
@@ -441,6 +457,9 @@ class ProtocolCommand:
441
457
  raise RequestFailedException(
442
458
  "No valid response received to '" + self.request.hex() + "' request."
443
459
  ) from None
460
+ finally:
461
+ if not protocol.keep_alive:
462
+ await protocol.close()
444
463
 
445
464
 
446
465
  class Aa55ProtocolCommand(ProtocolCommand):
@@ -485,8 +504,7 @@ class Aa55ProtocolCommand(ProtocolCommand):
485
504
  data[-2:] is checksum (plain sum of response data incl. header)
486
505
  """
487
506
  if len(data) <= 8 or len(data) != data[6] + 9:
488
- logger.debug("Response has unexpected length: %d, expected %d.", len(data), data[6] + 9)
489
- return False
507
+ raise PartialResponseException(len(data), data[6] + 9)
490
508
  elif response_type:
491
509
  data_rt_int = int.from_bytes(data[4:6], byteorder="big", signed=True)
492
510
  if int(response_type, 16) != data_rt_int:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: goodwe
3
- Version: 0.4.2
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
@@ -104,6 +104,8 @@ class TestModbus(TestCase):
104
104
  def test_validate_modbus_tcp_read_response(self):
105
105
  self.assert_tcp_response_ok('000100000007b4030445565345', 0x3, 310, 2)
106
106
  self.assert_tcp_response_ok('000100000007b4030400000002', 0x3, 331, 2)
107
+ # technically illegal, but work around Goodwe bug
108
+ self.assert_tcp_response_ok('000100000006f703020000', 0x3, 47510, 1)
107
109
  # length too short
108
110
  self.assert_tcp_response_partial('000100000007b403040000', 0x03, 331, 2)
109
111
  # failure code
goodwe-0.4.2/VERSION DELETED
@@ -1 +0,0 @@
1
- 0.4.2
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes