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.
- {goodwe-0.4.2/goodwe.egg-info → goodwe-0.4.3}/PKG-INFO +1 -1
- goodwe-0.4.3/VERSION +1 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/__init__.py +33 -35
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/inverter.py +3 -4
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/modbus.py +5 -1
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/protocol.py +44 -26
- {goodwe-0.4.2 → goodwe-0.4.3/goodwe.egg-info}/PKG-INFO +1 -1
- {goodwe-0.4.2 → goodwe-0.4.3}/tests/test_modbus.py +2 -0
- goodwe-0.4.2/VERSION +0 -1
- {goodwe-0.4.2 → goodwe-0.4.3}/LICENSE +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/README.md +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/const.py +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/dt.py +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/es.py +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/et.py +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/exceptions.py +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/model.py +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe/sensor.py +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe.egg-info/SOURCES.txt +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe.egg-info/dependency_links.txt +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/goodwe.egg-info/top_level.txt +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/pyproject.toml +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/setup.cfg +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/tests/test_dt.py +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/tests/test_es.py +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/tests/test_et.py +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/tests/test_protocol.py +0 -0
- {goodwe-0.4.2 → goodwe-0.4.3}/tests/test_sensor.py +0 -0
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
92
|
-
i =
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
97
|
+
except InverterError as ex:
|
|
98
|
+
failures.append(ex)
|
|
101
99
|
|
|
102
100
|
# Probe inverter specific protocols
|
|
103
|
-
for inv in
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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.
|
|
61
|
+
self._close_transport()
|
|
61
62
|
return self._lock
|
|
62
63
|
|
|
63
|
-
def
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
192
|
+
self._close_transport()
|
|
192
193
|
|
|
193
|
-
def
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|