goodwe 0.4.1__py3-none-any.whl → 0.4.2__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/dt.py +40 -13
- goodwe/es.py +7 -6
- goodwe/et.py +22 -16
- goodwe/exceptions.py +14 -0
- goodwe/inverter.py +11 -9
- goodwe/modbus.py +8 -9
- goodwe/protocol.py +112 -46
- goodwe/sensor.py +3 -3
- {goodwe-0.4.1.dist-info → goodwe-0.4.2.dist-info}/METADATA +1 -1
- goodwe-0.4.2.dist-info/RECORD +16 -0
- goodwe-0.4.1.dist-info/RECORD +0 -16
- {goodwe-0.4.1.dist-info → goodwe-0.4.2.dist-info}/LICENSE +0 -0
- {goodwe-0.4.1.dist-info → goodwe-0.4.2.dist-info}/WHEEL +0 -0
- {goodwe-0.4.1.dist-info → goodwe-0.4.2.dist-info}/top_level.txt +0 -0
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
632
|
-
|
|
633
|
-
|
|
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,10 +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
|
-
|
|
95
|
-
self.comm_addr: int = comm_addr
|
|
94
|
+
self.keep_alive: bool = True
|
|
96
95
|
|
|
97
96
|
self.model_name: str | None = None
|
|
98
97
|
self.serial_number: str | None = None
|
|
@@ -109,15 +108,15 @@ class Inverter(ABC):
|
|
|
109
108
|
|
|
110
109
|
def _read_command(self, offset: int, count: int) -> ProtocolCommand:
|
|
111
110
|
"""Create read protocol command."""
|
|
112
|
-
return self._protocol.read_command(
|
|
111
|
+
return self._protocol.read_command(offset, count)
|
|
113
112
|
|
|
114
113
|
def _write_command(self, register: int, value: int) -> ProtocolCommand:
|
|
115
114
|
"""Create write protocol command."""
|
|
116
|
-
return self._protocol.write_command(
|
|
115
|
+
return self._protocol.write_command(register, value)
|
|
117
116
|
|
|
118
117
|
def _write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
|
|
119
118
|
"""Create write multiple protocol command."""
|
|
120
|
-
return self._protocol.write_multi_command(
|
|
119
|
+
return self._protocol.write_multi_command(offset, values)
|
|
121
120
|
|
|
122
121
|
async def _read_from_socket(self, command: ProtocolCommand) -> ProtocolResponse:
|
|
123
122
|
try:
|
|
@@ -131,6 +130,9 @@ class Inverter(ABC):
|
|
|
131
130
|
except RequestFailedException as ex:
|
|
132
131
|
self._consecutive_failures_count += 1
|
|
133
132
|
raise RequestFailedException(ex.message, self._consecutive_failures_count) from None
|
|
133
|
+
finally:
|
|
134
|
+
if not self.keep_alive:
|
|
135
|
+
self._protocol.close_transport()
|
|
134
136
|
|
|
135
137
|
@abstractmethod
|
|
136
138
|
async def read_device_info(self):
|
|
@@ -270,11 +272,11 @@ class Inverter(ABC):
|
|
|
270
272
|
raise NotImplementedError()
|
|
271
273
|
|
|
272
274
|
@staticmethod
|
|
273
|
-
def _create_protocol(host: str, port: int, timeout: int, retries: int) -> InverterProtocol:
|
|
275
|
+
def _create_protocol(host: str, port: int, comm_addr: int, timeout: int, retries: int) -> InverterProtocol:
|
|
274
276
|
if port == 502:
|
|
275
|
-
return TcpInverterProtocol(host, port, timeout, retries)
|
|
277
|
+
return TcpInverterProtocol(host, port, comm_addr, timeout, retries)
|
|
276
278
|
else:
|
|
277
|
-
return UdpInverterProtocol(host, port, timeout, retries)
|
|
279
|
+
return UdpInverterProtocol(host, port, comm_addr, timeout, retries)
|
|
278
280
|
|
|
279
281
|
@staticmethod
|
|
280
282
|
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
|
-
|
|
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,18 @@ 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
|
+
if len(data) < expected_length:
|
|
226
|
+
raise PartialResponseException(len(data), expected_length)
|
|
227
|
+
|
|
225
228
|
if data[7] == MODBUS_READ_CMD:
|
|
226
229
|
if data[8] != value * 2:
|
|
227
230
|
logger.debug("Response has unexpected length: %d, expected %d.", data[8], value * 2)
|
|
228
231
|
return False
|
|
229
|
-
expected_length = data[8] + 9
|
|
230
|
-
if len(data) < expected_length:
|
|
231
|
-
logger.debug("Response is too short: %d, expected %d.", len(data), expected_length)
|
|
232
|
-
return False
|
|
233
232
|
elif data[7] in (MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD):
|
|
234
233
|
if len(data) < 12:
|
|
235
234
|
logger.debug("Response has unexpected length: %d, expected %d.", len(data), 14)
|
|
236
|
-
|
|
235
|
+
raise PartialResponseException(len(data), expected_length)
|
|
237
236
|
response_offset = int.from_bytes(data[8:10], byteorder='big', signed=False)
|
|
238
237
|
if response_offset != offset:
|
|
239
238
|
logger.debug("Response has wrong offset: %X, expected %X.", response_offset, offset)
|
goodwe/protocol.py
CHANGED
|
@@ -3,22 +3,35 @@ 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
|
|
@@ -27,6 +40,7 @@ class InverterProtocol:
|
|
|
27
40
|
self.protocol: asyncio.Protocol | None = None
|
|
28
41
|
self.response_future: Future | None = None
|
|
29
42
|
self.command: ProtocolCommand | None = None
|
|
43
|
+
self._partial_data: bytes | None = None
|
|
30
44
|
|
|
31
45
|
def _ensure_lock(self) -> asyncio.Lock:
|
|
32
46
|
"""Validate (or create) asyncio Lock.
|
|
@@ -43,45 +57,47 @@ class InverterProtocol:
|
|
|
43
57
|
logger.debug("Creating lock instance for current event loop.")
|
|
44
58
|
self._lock = asyncio.Lock()
|
|
45
59
|
self._running_loop = asyncio.get_event_loop()
|
|
46
|
-
self.
|
|
60
|
+
self.close_transport()
|
|
47
61
|
return self._lock
|
|
48
62
|
|
|
49
|
-
def
|
|
63
|
+
def close_transport(self) -> None:
|
|
64
|
+
"""Close the underlying transport/connection."""
|
|
50
65
|
raise NotImplementedError()
|
|
51
66
|
|
|
52
67
|
async def send_request(self, command: ProtocolCommand) -> Future:
|
|
68
|
+
"""Convert command to request and send it to inverter."""
|
|
53
69
|
raise NotImplementedError()
|
|
54
70
|
|
|
55
|
-
def read_command(self,
|
|
71
|
+
def read_command(self, offset: int, count: int) -> ProtocolCommand:
|
|
56
72
|
"""Create read protocol command."""
|
|
57
73
|
raise NotImplementedError()
|
|
58
74
|
|
|
59
|
-
def write_command(self,
|
|
75
|
+
def write_command(self, register: int, value: int) -> ProtocolCommand:
|
|
60
76
|
"""Create write protocol command."""
|
|
61
77
|
raise NotImplementedError()
|
|
62
78
|
|
|
63
|
-
def write_multi_command(self,
|
|
79
|
+
def write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
|
|
64
80
|
"""Create write multiple protocol command."""
|
|
65
81
|
raise NotImplementedError()
|
|
66
82
|
|
|
67
83
|
|
|
68
84
|
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)
|
|
85
|
+
def __init__(self, host: str, port: int, comm_addr: int, timeout: int = 1, retries: int = 3):
|
|
86
|
+
super().__init__(host, port, comm_addr, timeout, retries)
|
|
71
87
|
self._transport: asyncio.transports.DatagramTransport | None = None
|
|
72
88
|
self._retry: int = 0
|
|
73
89
|
|
|
74
|
-
def read_command(self,
|
|
90
|
+
def read_command(self, offset: int, count: int) -> ProtocolCommand:
|
|
75
91
|
"""Create read protocol command."""
|
|
76
|
-
return ModbusRtuReadCommand(
|
|
92
|
+
return ModbusRtuReadCommand(self._comm_addr, offset, count)
|
|
77
93
|
|
|
78
|
-
def write_command(self,
|
|
94
|
+
def write_command(self, register: int, value: int) -> ProtocolCommand:
|
|
79
95
|
"""Create write protocol command."""
|
|
80
|
-
return ModbusRtuWriteCommand(
|
|
96
|
+
return ModbusRtuWriteCommand(self._comm_addr, register, value)
|
|
81
97
|
|
|
82
|
-
def write_multi_command(self,
|
|
98
|
+
def write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
|
|
83
99
|
"""Create write multiple protocol command."""
|
|
84
|
-
return ModbusRtuWriteMultiCommand(
|
|
100
|
+
return ModbusRtuWriteMultiCommand(self._comm_addr, offset, values)
|
|
85
101
|
|
|
86
102
|
async def _connect(self) -> None:
|
|
87
103
|
if not self._transport or self._transport.is_closing():
|
|
@@ -100,7 +116,7 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
|
|
|
100
116
|
logger.debug("Socket closed with error: %s.", exc)
|
|
101
117
|
else:
|
|
102
118
|
logger.debug("Socket closed.")
|
|
103
|
-
self.
|
|
119
|
+
self.close_transport()
|
|
104
120
|
|
|
105
121
|
def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None:
|
|
106
122
|
"""On datagram received"""
|
|
@@ -108,22 +124,35 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
|
|
|
108
124
|
self._timer.cancel()
|
|
109
125
|
self._timer = None
|
|
110
126
|
try:
|
|
127
|
+
if self._partial_data:
|
|
128
|
+
logger.debug("Received another response fragment: %s.", data.hex())
|
|
129
|
+
data = self._partial_data + data
|
|
111
130
|
if self.command.validator(data):
|
|
112
|
-
|
|
131
|
+
if self._partial_data:
|
|
132
|
+
logger.debug("Composed fragmented response: %s", data.hex())
|
|
133
|
+
else:
|
|
134
|
+
logger.debug("Received: %s", data.hex())
|
|
135
|
+
self._partial_data = None
|
|
113
136
|
self.response_future.set_result(data)
|
|
114
137
|
else:
|
|
115
138
|
logger.debug("Received invalid response: %s", data.hex())
|
|
116
139
|
asyncio.get_running_loop().call_soon(self._retry_mechanism)
|
|
140
|
+
except PartialResponseException:
|
|
141
|
+
logger.debug("Received response fragment: %s", data.hex())
|
|
142
|
+
self._partial_data = data
|
|
143
|
+
return
|
|
144
|
+
except asyncio.InvalidStateError:
|
|
145
|
+
logger.debug("Response already handled: %s", data.hex())
|
|
117
146
|
except RequestRejectedException as ex:
|
|
118
147
|
logger.debug("Received exception response: %s", data.hex())
|
|
119
148
|
self.response_future.set_exception(ex)
|
|
120
|
-
self.
|
|
149
|
+
self.close_transport()
|
|
121
150
|
|
|
122
151
|
def error_received(self, exc: Exception) -> None:
|
|
123
152
|
"""On error received"""
|
|
124
153
|
logger.debug("Received error: %s", exc)
|
|
125
154
|
self.response_future.set_exception(exc)
|
|
126
|
-
self.
|
|
155
|
+
self.close_transport()
|
|
127
156
|
|
|
128
157
|
async def send_request(self, command: ProtocolCommand) -> Future:
|
|
129
158
|
"""Send message via transport"""
|
|
@@ -139,9 +168,12 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
|
|
|
139
168
|
"""Send message via transport"""
|
|
140
169
|
self.command = command
|
|
141
170
|
self.response_future = response_future
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
171
|
+
payload = command.request_bytes()
|
|
172
|
+
if self._retry > 0:
|
|
173
|
+
logger.debug("Sending: %s - retry #%s/%s", self.command, self._retry, self.retries)
|
|
174
|
+
else:
|
|
175
|
+
logger.debug("Sending: %s", self.command)
|
|
176
|
+
self._transport.sendto(payload)
|
|
145
177
|
self._timer = asyncio.get_running_loop().call_later(self.timeout, self._retry_mechanism)
|
|
146
178
|
|
|
147
179
|
def _retry_mechanism(self) -> None:
|
|
@@ -156,9 +188,9 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
|
|
|
156
188
|
else:
|
|
157
189
|
logger.debug("Max number of retries (%d) reached, request %s failed.", self.retries, self.command)
|
|
158
190
|
self.response_future.set_exception(MaxRetriesException)
|
|
159
|
-
self.
|
|
191
|
+
self.close_transport()
|
|
160
192
|
|
|
161
|
-
def
|
|
193
|
+
def close_transport(self) -> None:
|
|
162
194
|
if self._transport:
|
|
163
195
|
try:
|
|
164
196
|
self._transport.close()
|
|
@@ -171,22 +203,22 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
|
|
|
171
203
|
|
|
172
204
|
|
|
173
205
|
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)
|
|
206
|
+
def __init__(self, host: str, port: int, comm_addr: int, timeout: int = 1, retries: int = 0):
|
|
207
|
+
super().__init__(host, port, comm_addr, timeout, retries)
|
|
176
208
|
self._transport: asyncio.transports.Transport | None = None
|
|
177
209
|
self._retry: int = 0
|
|
178
210
|
|
|
179
|
-
def read_command(self,
|
|
211
|
+
def read_command(self, offset: int, count: int) -> ProtocolCommand:
|
|
180
212
|
"""Create read protocol command."""
|
|
181
|
-
return ModbusTcpReadCommand(
|
|
213
|
+
return ModbusTcpReadCommand(self._comm_addr, offset, count)
|
|
182
214
|
|
|
183
|
-
def write_command(self,
|
|
215
|
+
def write_command(self, register: int, value: int) -> ProtocolCommand:
|
|
184
216
|
"""Create write protocol command."""
|
|
185
|
-
return ModbusTcpWriteCommand(
|
|
217
|
+
return ModbusTcpWriteCommand(self._comm_addr, register, value)
|
|
186
218
|
|
|
187
|
-
def write_multi_command(self,
|
|
219
|
+
def write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
|
|
188
220
|
"""Create write multiple protocol command."""
|
|
189
|
-
return ModbusTcpWriteMultiCommand(
|
|
221
|
+
return ModbusTcpWriteMultiCommand(self._comm_addr, offset, values)
|
|
190
222
|
|
|
191
223
|
async def _connect(self) -> None:
|
|
192
224
|
if not self._transport or self._transport.is_closing():
|
|
@@ -195,6 +227,14 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
|
195
227
|
lambda: self,
|
|
196
228
|
host=self._host, port=self._port,
|
|
197
229
|
)
|
|
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))
|
|
198
238
|
|
|
199
239
|
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
|
200
240
|
"""On connection made"""
|
|
@@ -203,7 +243,7 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
|
203
243
|
|
|
204
244
|
def eof_received(self) -> None:
|
|
205
245
|
logger.debug("EOF received.")
|
|
206
|
-
self.
|
|
246
|
+
self.close_transport()
|
|
207
247
|
|
|
208
248
|
def connection_lost(self, exc: Optional[Exception]) -> None:
|
|
209
249
|
"""On connection lost"""
|
|
@@ -211,31 +251,44 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
|
211
251
|
logger.debug("Connection closed with error: %s.", exc)
|
|
212
252
|
else:
|
|
213
253
|
logger.debug("Connection closed.")
|
|
214
|
-
self.
|
|
254
|
+
self.close_transport()
|
|
215
255
|
|
|
216
256
|
def data_received(self, data: bytes) -> None:
|
|
217
257
|
"""On data received"""
|
|
218
258
|
if self._timer:
|
|
219
259
|
self._timer.cancel()
|
|
220
260
|
try:
|
|
261
|
+
if self._partial_data:
|
|
262
|
+
logger.debug("Received another response fragment: %s.", data.hex())
|
|
263
|
+
data = self._partial_data + data
|
|
221
264
|
if self.command.validator(data):
|
|
222
|
-
|
|
265
|
+
if self._partial_data:
|
|
266
|
+
logger.debug("Composed fragmented response: %s", data.hex())
|
|
267
|
+
else:
|
|
268
|
+
logger.debug("Received: %s", data.hex())
|
|
223
269
|
self._retry = 0
|
|
270
|
+
self._partial_data = None
|
|
224
271
|
self.response_future.set_result(data)
|
|
225
272
|
else:
|
|
226
273
|
logger.debug("Received invalid response: %s", data.hex())
|
|
227
274
|
self.response_future.set_exception(RequestRejectedException())
|
|
228
|
-
self.
|
|
275
|
+
self.close_transport()
|
|
276
|
+
except PartialResponseException:
|
|
277
|
+
logger.debug("Received response fragment: %s", data.hex())
|
|
278
|
+
self._partial_data = data
|
|
279
|
+
return
|
|
280
|
+
except asyncio.InvalidStateError:
|
|
281
|
+
logger.debug("Response already handled: %s", data.hex())
|
|
229
282
|
except RequestRejectedException as ex:
|
|
230
283
|
logger.debug("Received exception response: %s", data.hex())
|
|
231
284
|
self.response_future.set_exception(ex)
|
|
232
|
-
# self.
|
|
285
|
+
# self.close_transport()
|
|
233
286
|
|
|
234
287
|
def error_received(self, exc: Exception) -> None:
|
|
235
288
|
"""On error received"""
|
|
236
289
|
logger.debug("Received error: %s", exc)
|
|
237
290
|
self.response_future.set_exception(exc)
|
|
238
|
-
self.
|
|
291
|
+
self.close_transport()
|
|
239
292
|
|
|
240
293
|
async def send_request(self, command: ProtocolCommand) -> Future:
|
|
241
294
|
"""Send message via transport"""
|
|
@@ -253,11 +306,11 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
|
253
306
|
self._retry += 1
|
|
254
307
|
if self._lock and self._lock.locked():
|
|
255
308
|
self._lock.release()
|
|
256
|
-
self.
|
|
309
|
+
self.close_transport()
|
|
257
310
|
return await self.send_request(command)
|
|
258
311
|
else:
|
|
259
312
|
return self._max_retries_reached()
|
|
260
|
-
except (ConnectionRefusedError, TimeoutError, OSError, asyncio.TimeoutError)
|
|
313
|
+
except (ConnectionRefusedError, TimeoutError, OSError, asyncio.TimeoutError):
|
|
261
314
|
if self._retry < self.retries:
|
|
262
315
|
logger.debug("Connection refused error.")
|
|
263
316
|
self._retry += 1
|
|
@@ -274,9 +327,12 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
|
274
327
|
"""Send message via transport"""
|
|
275
328
|
self.command = command
|
|
276
329
|
self.response_future = response_future
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
330
|
+
payload = command.request_bytes()
|
|
331
|
+
if self._retry > 0:
|
|
332
|
+
logger.debug("Sending: %s - retry #%s/%s", self.command, self._retry, self.retries)
|
|
333
|
+
else:
|
|
334
|
+
logger.debug("Sending: %s", self.command)
|
|
335
|
+
self._transport.write(payload)
|
|
280
336
|
self._timer = asyncio.get_running_loop().call_later(self.timeout, self._timeout_mechanism)
|
|
281
337
|
|
|
282
338
|
def _timeout_mechanism(self) -> None:
|
|
@@ -287,16 +343,16 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
|
287
343
|
if self._timer:
|
|
288
344
|
logger.debug("Failed to receive response to %s in time (%ds).", self.command, self.timeout)
|
|
289
345
|
self._timer = None
|
|
290
|
-
self.
|
|
346
|
+
self.close_transport()
|
|
291
347
|
|
|
292
348
|
def _max_retries_reached(self) -> Future:
|
|
293
349
|
logger.debug("Max number of retries (%d) reached, request %s failed.", self.retries, self.command)
|
|
294
|
-
self.
|
|
350
|
+
self.close_transport()
|
|
295
351
|
self.response_future = asyncio.get_running_loop().create_future()
|
|
296
352
|
self.response_future.set_exception(MaxRetriesException)
|
|
297
353
|
return self.response_future
|
|
298
354
|
|
|
299
|
-
def
|
|
355
|
+
def close_transport(self) -> None:
|
|
300
356
|
if self._transport:
|
|
301
357
|
try:
|
|
302
358
|
self._transport.close()
|
|
@@ -354,6 +410,10 @@ class ProtocolCommand:
|
|
|
354
410
|
def __repr__(self):
|
|
355
411
|
return self.request.hex()
|
|
356
412
|
|
|
413
|
+
def request_bytes(self) -> bytes:
|
|
414
|
+
"""Return raw bytes payload, optionally pre-processed"""
|
|
415
|
+
return self.request
|
|
416
|
+
|
|
357
417
|
def trim_response(self, raw_response: bytes):
|
|
358
418
|
"""Trim raw response from header and checksum data"""
|
|
359
419
|
return raw_response
|
|
@@ -567,6 +627,12 @@ class ModbusTcpProtocolCommand(ProtocolCommand):
|
|
|
567
627
|
self.first_address: int = offset
|
|
568
628
|
self.value = value
|
|
569
629
|
|
|
630
|
+
def request_bytes(self) -> bytes:
|
|
631
|
+
"""Return raw bytes payload, optionally pre-processed"""
|
|
632
|
+
# Apply sequential Modbus/TCP transaction identifier
|
|
633
|
+
self.request = _next_tx() + self.request[2:]
|
|
634
|
+
return self.request
|
|
635
|
+
|
|
570
636
|
def trim_response(self, raw_response: bytes):
|
|
571
637
|
"""Trim raw response from header and checksum data"""
|
|
572
638
|
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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
goodwe/__init__.py,sha256=0Zwuri1cbJ2Qe24R2rEjDMTZeVtsh21YIx3KlRaXgWg,5742
|
|
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=-eRq6ND-BpLmj6vgYW0K0Oq3WvNcjjScbkalAzPH5ew,10494
|
|
8
|
+
goodwe/modbus.py,sha256=zT3W9ByANPaZd7T0XTqYGBaVo9PEwyg8jus12mRxCPU,8211
|
|
9
|
+
goodwe/model.py,sha256=dWBjMFJMnhZoUdDd9fGT54DERDANz4TirK0Wy8kWMbk,2068
|
|
10
|
+
goodwe/protocol.py,sha256=m4n1VAonXLBswFEjUcvKXEPV2WcOv_-MDMAefpsQ_-g,27703
|
|
11
|
+
goodwe/sensor.py,sha256=buPG8BcgZmRDqaMrLQUACLHB85U134qG6qo_ggsu48A,37679
|
|
12
|
+
goodwe-0.4.2.dist-info/LICENSE,sha256=aZAhk3lRdYT1YZV-IKRHISEcc_KNUmgfuNO3QhRamNM,1073
|
|
13
|
+
goodwe-0.4.2.dist-info/METADATA,sha256=gOkkNodwpHtUf_743Nc7jCKpdxjwX_L5DSr2POJDjs8,3376
|
|
14
|
+
goodwe-0.4.2.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
15
|
+
goodwe-0.4.2.dist-info/top_level.txt,sha256=kKoiqiVvAxDaDJYMZZQLgHQj9cuWT1MXLfXElTDuf8s,7
|
|
16
|
+
goodwe-0.4.2.dist-info/RECORD,,
|
goodwe-0.4.1.dist-info/RECORD
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|