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.
Files changed (47) hide show
  1. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/PKG-INFO +1 -1
  2. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ads_symbol_parser.py +2 -0
  3. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/transport.py +149 -48
  4. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/pyproject.toml +1 -1
  5. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/.gitignore +0 -0
  6. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/.vscode/settings.json +0 -0
  7. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/LICENSE +0 -0
  8. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/README.md +0 -0
  9. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ads_client.py +0 -0
  10. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ads_error_codes.py +0 -0
  11. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ads_notifications.py +0 -0
  12. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ads_symbol_cache.py +0 -0
  13. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ams_address.py +0 -0
  14. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ams_header.py +0 -0
  15. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/ams_tcp_header.py +0 -0
  16. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_add_notification.py +0 -0
  17. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_command.py +0 -0
  18. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_delete_notification.py +0 -0
  19. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_read.py +0 -0
  20. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_read_device_info.py +0 -0
  21. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_read_state.py +0 -0
  22. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_read_write.py +0 -0
  23. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_write.py +0 -0
  24. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/ads_write_state.py +0 -0
  25. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/commands/errors.py +0 -0
  26. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/errors.py +0 -0
  27. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_enable_route.py +0 -0
  28. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_function.py +0 -0
  29. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_sum_read.py +0 -0
  30. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_sum_read_write.py +0 -0
  31. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
  32. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
  33. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_symbol_info_by_name_ex.py +0 -0
  34. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_symbol_table_version.py +0 -0
  35. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_symbol_upload.py +0 -0
  36. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/functions/ads_symbol_upload_info.py +0 -0
  37. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/stream.py +0 -0
  38. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/aioads/utils/local_ip.py +0 -0
  39. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/docs/transmission_mode.md +0 -0
  40. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/examples/read_cmd_reuse_mqtt.py +0 -0
  41. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/examples/read_cycles.py +0 -0
  42. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/examples/read_cycles_mqtt.py +0 -0
  43. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/examples/read_multiple.py +0 -0
  44. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/examples/read_multiple_mqtt.py +0 -0
  45. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/examples/read_single.py +0 -0
  46. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/pdm.lock +0 -0
  47. {aioads-0.1.0.dev2 → aioads-0.1.0.dev3}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aioads
3
- Version: 0.1.0.dev2
3
+ Version: 0.1.0.dev3
4
4
  Summary: An asynchronous Python library for communicating with Beckhoff TwinCAT PLCs
5
5
  Author-email: MkKiefer <102972583+MkKiefer@users.noreply.github.com>
6
6
  License: MIT
@@ -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._reader_task: None | asyncio.Task[None] = None
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._stream is not None:
250
+ if self._supervisor_task is not None and not self._supervisor_task.done():
225
251
  return
226
252
 
227
- self._stream = await asyncio.wait_for(
228
- asyncio.open_connection(self.ip, self.port), self.CONNECT_TIMEOUT
229
- )
230
- self._reader_task = asyncio.create_task(self._reader())
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
- if self._stream is None:
237
- return
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
- await self.cancel_task(self._reader_task)
240
- _, writer = self._stream
241
- writer.close()
242
- await writer.wait_closed()
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
- self._stream = None
245
- self._reader_task = None
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._stream is None:
257
- raise ConnectionError("Not connected to the remote PLC")
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
- _, writer = self._stream
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 _reader(self):
289
- """
290
- Reader task that continuously reads from the TCP stream.
291
- """
292
- try:
293
- if not self._stream:
294
- raise ConnectionError("Not connected to the remote PLC")
295
-
296
- reader, _ = self._stream
297
- while True:
298
- # Read AmsTcpHeader
299
- tcp_header_bytes = await reader.readexactly(AmsTcpHeader.FIXED_SIZE)
300
- tcp_header = AmsTcpHeader.deserialize(tcp_header_bytes)
301
- payload_bytes = await reader.readexactly(tcp_header.length)
302
-
303
- ams_message_stream = AdsStream(memoryview(payload_bytes))
304
- ams_header = AmsHeader.deserialize(ams_message_stream)
305
- ams_command = ams_message_stream.sub_stream(
306
- ams_header.command_length)
307
-
308
- if AdsCommandState.ADS_RESPONSE in ams_header.command_flags:
309
- await self._handle_response(ams_header, ams_command)
310
-
311
- elif AdsCommandState.ADS_REQUEST in ams_header.command_flags:
312
- await self._handle_request(ams_header, ams_command)
313
- except asyncio.CancelledError:
314
- pass
315
- except Exception as e: # pylint: disable=broad-exception-caught
316
- self.logger.error("Reader task encountered an error", exc_info=e)
317
- self.cancel_pending_requests(e)
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aioads"
3
- version = "0.1.0.dev2"
3
+ version = "0.1.0.dev3"
4
4
  description = "An asynchronous Python library for communicating with Beckhoff TwinCAT PLCs"
5
5
  authors = [
6
6
  { name = "MkKiefer", email = "102972583+MkKiefer@users.noreply.github.com" },
File without changes
File without changes
File without changes
File without changes