goodwe 0.3.6__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 -12
- goodwe/es.py +14 -14
- goodwe/et.py +33 -67
- goodwe/inverter.py +35 -38
- goodwe/modbus.py +106 -6
- goodwe/model.py +0 -4
- goodwe/protocol.py +338 -58
- goodwe/sensor.py +7 -23
- {goodwe-0.3.6.dist-info → goodwe-0.4.0.dist-info}/METADATA +12 -7
- goodwe-0.4.0.dist-info/RECORD +16 -0
- goodwe-0.3.6.dist-info/RECORD +0 -16
- {goodwe-0.3.6.dist-info → goodwe-0.4.0.dist-info}/LICENSE +0 -0
- {goodwe-0.3.6.dist-info → goodwe-0.4.0.dist-info}/WHEEL +0 -0
- {goodwe-0.3.6.dist-info → goodwe-0.4.0.dist-info}/top_level.txt +0 -0
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/model.py
CHANGED
|
@@ -48,7 +48,3 @@ def is_2_battery(inverter: Inverter) -> bool:
|
|
|
48
48
|
def is_745_platform(inverter: Inverter) -> bool:
|
|
49
49
|
return any(model in inverter.serial_number for model in PLATFORM_745_LV_MODELS) or any(
|
|
50
50
|
model in inverter.serial_number for model in PLATFORM_745_HV_MODELS)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def is_753_platform(inverter: Inverter) -> bool:
|
|
54
|
-
return any(model in inverter.serial_number for model in PLATFORM_753_MODELS)
|
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)
|