aioads 0.1.0.dev2__tar.gz → 0.1.0.dev3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/PKG-INFO +1 -1
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ads_symbol_parser.py +2 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/transport.py +149 -48
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/pyproject.toml +1 -1
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/.gitignore +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/.vscode/settings.json +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/LICENSE +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/README.md +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ads_client.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ads_error_codes.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ads_notifications.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ads_symbol_cache.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ams_address.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ams_header.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ams_tcp_header.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_add_notification.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_command.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_delete_notification.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_read.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_read_device_info.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_read_state.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_read_write.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_write.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_write_state.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/errors.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/errors.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_enable_route.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_function.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_sum_read.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_sum_read_write.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_symbol_info_by_name_ex.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_symbol_table_version.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_symbol_upload.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_symbol_upload_info.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/stream.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/utils/local_ip.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/docs/transmission_mode.md +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/examples/read_cmd_reuse_mqtt.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/examples/read_cycles.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/examples/read_cycles_mqtt.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/examples/read_multiple.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/examples/read_multiple_mqtt.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/examples/read_single.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/pdm.lock +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/tests/__init__.py +0 -0
|
@@ -67,6 +67,8 @@ class PrimitiveTypeParser(ISymbolParser):
|
|
|
67
67
|
|
|
68
68
|
def parse(self, data_type: AdsSymbolDataType, type_name: str, raw_data: AdsStream) -> Any:
|
|
69
69
|
"""Parse a primitive data type."""
|
|
70
|
+
if data_type == AdsSymbolDataType.VOID:
|
|
71
|
+
return None
|
|
70
72
|
if data_type == AdsSymbolDataType.STRING:
|
|
71
73
|
return self._parse_string(type_name, raw_data)
|
|
72
74
|
if data_type == AdsSymbolDataType.WSTRING:
|
|
@@ -3,7 +3,9 @@ TCP transport implementation for ADS protocol.
|
|
|
3
3
|
"""
|
|
4
4
|
import asyncio
|
|
5
5
|
import contextlib
|
|
6
|
+
import enum
|
|
6
7
|
import logging
|
|
8
|
+
import random
|
|
7
9
|
import re
|
|
8
10
|
from abc import ABC
|
|
9
11
|
from dataclasses import dataclass
|
|
@@ -64,6 +66,23 @@ class ITransport(ABC):
|
|
|
64
66
|
raise NotImplementedError
|
|
65
67
|
|
|
66
68
|
|
|
69
|
+
class ConnectionState(enum.Enum):
|
|
70
|
+
"""
|
|
71
|
+
Lifecycle state of a transport connection.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
DISCONNECTED = enum.auto()
|
|
75
|
+
"""Never connected, or the initial connect failed."""
|
|
76
|
+
CONNECTING = enum.auto()
|
|
77
|
+
"""Initial connect attempt in progress."""
|
|
78
|
+
CONNECTED = enum.auto()
|
|
79
|
+
"""Connected; requests are allowed."""
|
|
80
|
+
RECONNECTING = enum.auto()
|
|
81
|
+
"""Connection dropped; the supervisor is re-establishing it. Requests fail fast."""
|
|
82
|
+
CLOSED = enum.auto()
|
|
83
|
+
"""Intentionally closed via disconnect(); the supervisor must not reconnect."""
|
|
84
|
+
|
|
85
|
+
|
|
67
86
|
class BaseTransport:
|
|
68
87
|
"""
|
|
69
88
|
Common base class for all transport implementations, providing common functionality for:
|
|
@@ -189,6 +208,8 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
189
208
|
|
|
190
209
|
REQUEST_TIMEOUT = 120.0
|
|
191
210
|
CONNECT_TIMEOUT = 30.0
|
|
211
|
+
RECONNECT_INITIAL_BACKOFF = 1.0
|
|
212
|
+
RECONNECT_MAX_BACKOFF = 30.0
|
|
192
213
|
|
|
193
214
|
def __init__(
|
|
194
215
|
self,
|
|
@@ -210,7 +231,8 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
210
231
|
self._stream: None | tuple[asyncio.StreamReader,
|
|
211
232
|
asyncio.StreamWriter] = None
|
|
212
233
|
self._stream_lock = asyncio.Lock()
|
|
213
|
-
self.
|
|
234
|
+
self._supervisor_task: None | asyncio.Task[None] = None
|
|
235
|
+
self._state = ConnectionState.DISCONNECTED
|
|
214
236
|
|
|
215
237
|
def set_notification_callback(
|
|
216
238
|
self, callback: Callable[[AmsHeader, AdsStream], Coroutine[None, None, None]]
|
|
@@ -220,29 +242,63 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
220
242
|
async def connect(self):
|
|
221
243
|
"""
|
|
222
244
|
Connect to the remote PLC via TCP.
|
|
245
|
+
|
|
246
|
+
Performs the initial connection synchronously and raises on failure.
|
|
247
|
+
On success a background supervisor task takes over the read loop and
|
|
248
|
+
transparently reconnects (with backoff) if the connection drops.
|
|
223
249
|
"""
|
|
224
|
-
if self.
|
|
250
|
+
if self._supervisor_task is not None and not self._supervisor_task.done():
|
|
225
251
|
return
|
|
226
252
|
|
|
227
|
-
self.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
253
|
+
self._state = ConnectionState.CONNECTING
|
|
254
|
+
try:
|
|
255
|
+
await self._open()
|
|
256
|
+
except Exception:
|
|
257
|
+
self._state = ConnectionState.DISCONNECTED
|
|
258
|
+
raise
|
|
259
|
+
self._supervisor_task = asyncio.create_task(self._supervise())
|
|
231
260
|
|
|
232
261
|
async def disconnect(self):
|
|
233
262
|
"""
|
|
234
|
-
Disconnect from the remote PLC.
|
|
263
|
+
Disconnect from the remote PLC and stop the supervisor.
|
|
264
|
+
|
|
265
|
+
Sets CLOSED first so the supervisor does not attempt to reconnect, then
|
|
266
|
+
tears down the stream (which unblocks the read loop) and awaits the
|
|
267
|
+
supervisor task.
|
|
235
268
|
"""
|
|
236
|
-
|
|
237
|
-
|
|
269
|
+
self._state = ConnectionState.CLOSED
|
|
270
|
+
await self._teardown_stream(ConnectionError("Transport disconnected"))
|
|
271
|
+
await self.cancel_task(self._supervisor_task)
|
|
272
|
+
self._supervisor_task = None
|
|
238
273
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
274
|
+
async def _open(self):
|
|
275
|
+
"""
|
|
276
|
+
Open the TCP connection and publish it as the active stream.
|
|
277
|
+
Raises on failure; callers own the surrounding state transitions.
|
|
278
|
+
"""
|
|
279
|
+
reader, writer = await asyncio.wait_for(
|
|
280
|
+
asyncio.open_connection(self.ip, self.port), self.CONNECT_TIMEOUT
|
|
281
|
+
)
|
|
282
|
+
async with self._stream_lock:
|
|
283
|
+
self._stream = (reader, writer)
|
|
284
|
+
self._state = ConnectionState.CONNECTED
|
|
243
285
|
|
|
244
|
-
|
|
245
|
-
|
|
286
|
+
async def _teardown_stream(self, ex: BaseException):
|
|
287
|
+
"""
|
|
288
|
+
Fail all in-flight requests and close the current stream (if any).
|
|
289
|
+
|
|
290
|
+
In-flight requests are intentionally *not* replayed: a write may already
|
|
291
|
+
have been applied on the PLC, so resending could double-apply it.
|
|
292
|
+
"""
|
|
293
|
+
self.cancel_pending_requests(ex)
|
|
294
|
+
async with self._stream_lock:
|
|
295
|
+
stream = self._stream
|
|
296
|
+
self._stream = None
|
|
297
|
+
if stream is not None:
|
|
298
|
+
_, writer = stream
|
|
299
|
+
writer.close()
|
|
300
|
+
with contextlib.suppress(Exception):
|
|
301
|
+
await writer.wait_closed()
|
|
246
302
|
|
|
247
303
|
async def request(
|
|
248
304
|
self, command_payload: bytes, command_id: AdsCommandId, ams_address: AmsAddress
|
|
@@ -253,8 +309,10 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
253
309
|
|
|
254
310
|
:return: A tuple containing the AmsHeader and the payload bytes
|
|
255
311
|
"""
|
|
256
|
-
if self.
|
|
257
|
-
raise ConnectionError(
|
|
312
|
+
if self._state is not ConnectionState.CONNECTED:
|
|
313
|
+
raise ConnectionError(
|
|
314
|
+
f"Not connected to the remote (state: {self._state.name})"
|
|
315
|
+
)
|
|
258
316
|
|
|
259
317
|
invoke_id = self.get_next_invoke_id()
|
|
260
318
|
|
|
@@ -276,7 +334,14 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
276
334
|
|
|
277
335
|
async with self.subscribe_request(invoke_id) as response_future:
|
|
278
336
|
async with self._stream_lock:
|
|
279
|
-
|
|
337
|
+
stream = self._stream
|
|
338
|
+
if stream is None:
|
|
339
|
+
# A teardown raced this send; surface the failure it already
|
|
340
|
+
# set on the future rather than a fresh, unretrieved one.
|
|
341
|
+
if response_future.done():
|
|
342
|
+
return await response_future
|
|
343
|
+
raise ConnectionError("Not connected to the remote")
|
|
344
|
+
_, writer = stream
|
|
280
345
|
# Send request
|
|
281
346
|
writer.write(tcp_header_bytes +
|
|
282
347
|
ams_header_bytes + command_payload)
|
|
@@ -285,36 +350,72 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
285
350
|
response_future, timeout=self.REQUEST_TIMEOUT
|
|
286
351
|
)
|
|
287
352
|
|
|
288
|
-
async def
|
|
289
|
-
"""
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
353
|
+
async def _supervise(self):
|
|
354
|
+
"""
|
|
355
|
+
Own the connection lifecycle: run the read loop, and when the connection
|
|
356
|
+
drops, fail in-flight requests and reconnect with exponential backoff.
|
|
357
|
+
|
|
358
|
+
This is the only place that reconnects, so concurrent senders never
|
|
359
|
+
trigger competing reconnect attempts. Senders fail fast instead (see
|
|
360
|
+
:meth:`request`), which also respects the Beckhoff single-connection
|
|
361
|
+
backoff/lockout behavior.
|
|
362
|
+
"""
|
|
363
|
+
backoff = self.RECONNECT_INITIAL_BACKOFF
|
|
364
|
+
while self._state is not ConnectionState.CLOSED:
|
|
365
|
+
try:
|
|
366
|
+
await self._read_loop()
|
|
367
|
+
except asyncio.CancelledError:
|
|
368
|
+
raise
|
|
369
|
+
except Exception as e:
|
|
370
|
+
self.logger.info("Connection lost: %s", e)
|
|
371
|
+
|
|
372
|
+
if self._state is ConnectionState.CLOSED:
|
|
373
|
+
break
|
|
374
|
+
|
|
375
|
+
self._state = ConnectionState.RECONNECTING
|
|
376
|
+
await self._teardown_stream(ConnectionError("Connection lost"))
|
|
377
|
+
|
|
378
|
+
while self._state is not ConnectionState.CLOSED:
|
|
379
|
+
try:
|
|
380
|
+
await self._open()
|
|
381
|
+
self.logger.info("Reconnected to the remote")
|
|
382
|
+
backoff = self.RECONNECT_INITIAL_BACKOFF
|
|
383
|
+
break
|
|
384
|
+
except asyncio.CancelledError:
|
|
385
|
+
raise
|
|
386
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
387
|
+
self.logger.info(
|
|
388
|
+
"Reconnect failed: %s, retrying in %.1fs", e, backoff
|
|
389
|
+
)
|
|
390
|
+
await asyncio.sleep(backoff + random.uniform(0, backoff))
|
|
391
|
+
backoff = min(backoff * 2, self.RECONNECT_MAX_BACKOFF)
|
|
392
|
+
|
|
393
|
+
async def _read_loop(self):
|
|
394
|
+
"""
|
|
395
|
+
Continuously read and dispatch messages from the active stream.
|
|
396
|
+
Returns/raises when the connection drops; the supervisor handles recovery.
|
|
397
|
+
"""
|
|
398
|
+
stream = self._stream
|
|
399
|
+
if stream is None:
|
|
400
|
+
raise ConnectionError("Not connected to the remote PLC")
|
|
401
|
+
|
|
402
|
+
reader, _ = stream
|
|
403
|
+
while True:
|
|
404
|
+
# Read AmsTcpHeader
|
|
405
|
+
tcp_header_bytes = await reader.readexactly(AmsTcpHeader.FIXED_SIZE)
|
|
406
|
+
tcp_header = AmsTcpHeader.deserialize(tcp_header_bytes)
|
|
407
|
+
payload_bytes = await reader.readexactly(tcp_header.length)
|
|
408
|
+
|
|
409
|
+
ams_message_stream = AdsStream(memoryview(payload_bytes))
|
|
410
|
+
ams_header = AmsHeader.deserialize(ams_message_stream)
|
|
411
|
+
ams_command = ams_message_stream.sub_stream(
|
|
412
|
+
ams_header.command_length)
|
|
413
|
+
|
|
414
|
+
if AdsCommandState.ADS_RESPONSE in ams_header.command_flags:
|
|
415
|
+
await self._handle_response(ams_header, ams_command)
|
|
416
|
+
|
|
417
|
+
elif AdsCommandState.ADS_REQUEST in ams_header.command_flags:
|
|
418
|
+
await self._handle_request(ams_header, ams_command)
|
|
318
419
|
|
|
319
420
|
|
|
320
421
|
@dataclass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|