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