aioads 0.1.0.dev2__tar.gz → 0.1.0.dev4__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 (88) hide show
  1. aioads-0.1.0.dev4/.github/dependabot.yml +20 -0
  2. aioads-0.1.0.dev4/.github/workflows/ci.yml +51 -0
  3. aioads-0.1.0.dev4/.github/workflows/publish.yml +62 -0
  4. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/PKG-INFO +1 -1
  5. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ads_client.py +43 -20
  6. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ads_symbol_parser.py +2 -0
  7. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_add_notification.py +1 -1
  8. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/errors.py +4 -1
  9. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/transport.py +158 -42
  10. aioads-0.1.0.dev4/docs/unittest_style_guide.html +1739 -0
  11. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/pyproject.toml +6 -1
  12. aioads-0.1.0.dev4/tests/builders.py +63 -0
  13. aioads-0.1.0.dev4/tests/unit/__init__.py +0 -0
  14. aioads-0.1.0.dev4/tests/unit/commands/__init__.py +0 -0
  15. aioads-0.1.0.dev4/tests/unit/commands/test_ads_add_notification.py +131 -0
  16. aioads-0.1.0.dev4/tests/unit/commands/test_ads_command.py +63 -0
  17. aioads-0.1.0.dev4/tests/unit/commands/test_ads_delete_notification.py +83 -0
  18. aioads-0.1.0.dev4/tests/unit/commands/test_ads_read.py +124 -0
  19. aioads-0.1.0.dev4/tests/unit/commands/test_ads_read_device_info.py +147 -0
  20. aioads-0.1.0.dev4/tests/unit/commands/test_ads_read_state.py +137 -0
  21. aioads-0.1.0.dev4/tests/unit/commands/test_ads_read_write.py +98 -0
  22. aioads-0.1.0.dev4/tests/unit/commands/test_ads_write.py +117 -0
  23. aioads-0.1.0.dev4/tests/unit/commands/test_ads_write_state.py +85 -0
  24. aioads-0.1.0.dev4/tests/unit/commands/test_errors.py +50 -0
  25. aioads-0.1.0.dev4/tests/unit/functions/__init__.py +0 -0
  26. aioads-0.1.0.dev4/tests/unit/functions/test_ads_enable_route.py +71 -0
  27. aioads-0.1.0.dev4/tests/unit/functions/test_ads_function.py +32 -0
  28. aioads-0.1.0.dev4/tests/unit/functions/test_ads_sum_read.py +102 -0
  29. aioads-0.1.0.dev4/tests/unit/functions/test_ads_sum_read_write.py +96 -0
  30. aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_datatype_by_name.py +137 -0
  31. aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_datatype_upload.py +82 -0
  32. aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +150 -0
  33. aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_table_version.py +74 -0
  34. aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_upload.py +78 -0
  35. aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_upload_info.py +77 -0
  36. aioads-0.1.0.dev4/tests/unit/test_ads_client.py +184 -0
  37. aioads-0.1.0.dev4/tests/unit/test_ads_error_codes.py +77 -0
  38. aioads-0.1.0.dev4/tests/unit/test_ads_notifications.py +161 -0
  39. aioads-0.1.0.dev4/tests/unit/test_ads_symbol_cache.py +182 -0
  40. aioads-0.1.0.dev4/tests/unit/test_ads_symbol_parser.py +244 -0
  41. aioads-0.1.0.dev4/tests/unit/test_ams_address.py +61 -0
  42. aioads-0.1.0.dev4/tests/unit/test_ams_header.py +84 -0
  43. aioads-0.1.0.dev4/tests/unit/test_ams_tcp_header.py +62 -0
  44. aioads-0.1.0.dev4/tests/unit/test_errors.py +51 -0
  45. aioads-0.1.0.dev4/tests/unit/test_stream.py +133 -0
  46. aioads-0.1.0.dev4/tests/unit/test_transport.py +220 -0
  47. aioads-0.1.0.dev4/tests/unit/utils/__init__.py +0 -0
  48. aioads-0.1.0.dev4/tests/unit/utils/test_local_ip.py +60 -0
  49. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/.gitignore +0 -0
  50. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/.vscode/settings.json +0 -0
  51. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/LICENSE +0 -0
  52. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/README.md +0 -0
  53. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ads_error_codes.py +0 -0
  54. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ads_notifications.py +0 -0
  55. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ads_symbol_cache.py +0 -0
  56. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ams_address.py +0 -0
  57. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ams_header.py +0 -0
  58. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ams_tcp_header.py +0 -0
  59. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_command.py +0 -0
  60. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_delete_notification.py +0 -0
  61. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_read.py +0 -0
  62. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_read_device_info.py +0 -0
  63. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_read_state.py +0 -0
  64. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_read_write.py +0 -0
  65. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_write.py +0 -0
  66. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_write_state.py +0 -0
  67. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/errors.py +0 -0
  68. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_enable_route.py +0 -0
  69. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_function.py +0 -0
  70. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_sum_read.py +0 -0
  71. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_sum_read_write.py +0 -0
  72. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
  73. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
  74. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_info_by_name_ex.py +0 -0
  75. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_table_version.py +0 -0
  76. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_upload.py +0 -0
  77. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_upload_info.py +0 -0
  78. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/stream.py +0 -0
  79. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/utils/local_ip.py +0 -0
  80. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/docs/transmission_mode.md +0 -0
  81. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/examples/read_cmd_reuse_mqtt.py +0 -0
  82. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/examples/read_cycles.py +0 -0
  83. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/examples/read_cycles_mqtt.py +0 -0
  84. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/examples/read_multiple.py +0 -0
  85. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/examples/read_multiple_mqtt.py +0 -0
  86. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/examples/read_single.py +0 -0
  87. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/pdm.lock +0 -0
  88. {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/tests/__init__.py +0 -0
@@ -0,0 +1,20 @@
1
+ version: 2
2
+ updates:
3
+ # Keep the SHA-pinned GitHub Actions current (Dependabot updates both the
4
+ # pinned SHA and the trailing version comment).
5
+ - package-ecosystem: github-actions
6
+ directory: "/"
7
+ schedule:
8
+ interval: weekly
9
+ groups:
10
+ actions:
11
+ patterns: ["*"]
12
+
13
+ # Python dev/runtime dependencies tracked in pdm.lock / pyproject.toml.
14
+ - package-ecosystem: pip
15
+ directory: "/"
16
+ schedule:
17
+ interval: weekly
18
+ groups:
19
+ python:
20
+ patterns: ["*"]
@@ -0,0 +1,51 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ concurrency:
10
+ group: ci-${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ test:
18
+ name: Test (Python ${{ matrix.python-version }})
19
+ runs-on: ubuntu-latest
20
+ strategy:
21
+ fail-fast: false
22
+ matrix:
23
+ python-version: ["3.12", "3.13"]
24
+
25
+ steps:
26
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
27
+ with:
28
+ persist-credentials: false
29
+
30
+ - name: Set up PDM
31
+ uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
32
+ with:
33
+ python-version: ${{ matrix.python-version }}
34
+ cache: true
35
+
36
+ - name: Install dependencies
37
+ run: pdm install
38
+
39
+ - name: Lint
40
+ run: pdm run lint
41
+
42
+ - name: Run tests with coverage
43
+ run: pdm run coverage
44
+
45
+ - name: Upload coverage artifact
46
+ if: matrix.python-version == '3.12'
47
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
48
+ with:
49
+ name: coverage-xml
50
+ path: coverage.xml
51
+ if-no-files-found: ignore
@@ -0,0 +1,62 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ # Avoid two releases racing to publish the same version.
12
+ concurrency:
13
+ group: publish-${{ github.workflow }}-${{ github.ref }}
14
+ cancel-in-progress: false
15
+
16
+ jobs:
17
+ build:
18
+ name: Build distribution
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
22
+ with:
23
+ persist-credentials: false
24
+
25
+ - name: Set up PDM
26
+ uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
27
+ with:
28
+ python-version: "3.12"
29
+
30
+ - name: Build sdist and wheel
31
+ run: pdm build
32
+
33
+ - name: Upload build artifacts
34
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+
39
+ publish:
40
+ name: Publish to PyPI
41
+ needs: build
42
+ runs-on: ubuntu-latest
43
+ # Only publish for real releases, not manual test runs.
44
+ if: github.event_name == 'release'
45
+ environment:
46
+ name: pypi
47
+ url: https://pypi.org/project/aioads/
48
+ permissions:
49
+ # IMPORTANT: required for OIDC trusted publishing.
50
+ id-token: write
51
+ steps:
52
+ - name: Download build artifacts
53
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
54
+ with:
55
+ name: dist
56
+ path: dist/
57
+
58
+ - name: Publish to PyPI
59
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
60
+ with:
61
+ # Sign the published distributions with PEP 740 build provenance.
62
+ attestations: true
@@ -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.dev4
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
@@ -266,36 +266,59 @@ class AdsClient:
266
266
  ) -> dict[str, SymbolReadResult]:
267
267
  """
268
268
  Read multiple symbols by their names from the ADS device.
269
+
270
+ The returned dict is keyed by the symbol names exactly as requested,
271
+ preserving their original casing.
269
272
  """
270
273
  symbol_infos = await self._cache.read_symbol_infos_by_names(symbol_names)
271
274
  if raise_errors:
272
275
  self._raise_if_error(symbol_infos)
273
276
 
274
- read_commands = [
275
- AdsReadCommand(
276
- transport=self.transport,
277
- ams_address=self.dst_address,
278
- idx_group=symbol_info.idx_group,
279
- idx_offset=symbol_info.idx_offset,
280
- length=symbol_info.idx_length,
281
- )
282
- for _, symbol_info in symbol_infos.values()
283
- ]
277
+ output: dict[str, SymbolReadResult] = {}
278
+
279
+ # Only symbols whose info resolved successfully can be read. Symbols
280
+ # with a lookup error are reported as-is; building a read command from
281
+ # their (invalid) index group/offset would misalign the bulk response.
282
+ readable: list[tuple[str, SymbolInfo]] = []
283
+ for requested_name, (error_code, symbol_info) in symbol_infos.items():
284
+ if not error_code.ok:
285
+ output[requested_name] = SymbolReadResult(error_code, None)
286
+ else:
287
+ readable.append((requested_name, symbol_info))
288
+
289
+ if not readable:
290
+ return output
291
+
284
292
  function = AdsSumRead(
285
293
  transport=self.transport,
286
294
  ams_address=self.dst_address,
287
- commands=read_commands,
295
+ commands=[
296
+ AdsReadCommand(
297
+ transport=self.transport,
298
+ ams_address=self.dst_address,
299
+ idx_group=symbol_info.idx_group,
300
+ idx_offset=symbol_info.idx_offset,
301
+ length=symbol_info.idx_length,
302
+ )
303
+ for _, symbol_info in readable
304
+ ],
288
305
  )
289
-
290
306
  response = await function.execute()
291
- output: dict[str, SymbolReadResult] = {}
292
307
 
308
+ loop = asyncio.get_running_loop()
309
+ parse_names: list[str] = []
293
310
  tasks = []
294
- for (_, resp_payload), (error_code, symbol_info) in zip(
295
- response, symbol_infos.values()
311
+ for (requested_name, symbol_info), (read_response, resp_payload) in zip(
312
+ readable, response
296
313
  ):
314
+ # A failed read yields no usable payload, so skip parsing it.
315
+ if read_response.error_code != 0:
316
+ output[requested_name] = SymbolReadResult(
317
+ read_response.error_code, None)
318
+ continue
319
+ parse_names.append(requested_name)
297
320
  tasks.append(
298
- asyncio.get_running_loop().run_in_executor(
321
+ loop.run_in_executor(
299
322
  self.parser_pool,
300
323
  self.parser.parse,
301
324
  symbol_info.data_type,
@@ -303,11 +326,11 @@ class AdsClient:
303
326
  resp_payload,
304
327
  )
305
328
  )
306
- parsed = await asyncio.gather(*tasks)
307
329
 
308
- for symbol_data, (error_code, symbol_info) in zip(parsed, symbol_infos.values()):
309
- output[symbol_info.symbol_name] = SymbolReadResult(
310
- error_code, symbol_data)
330
+ parsed = await asyncio.gather(*tasks)
331
+ for requested_name, symbol_data in zip(parse_names, parsed):
332
+ output[requested_name] = SymbolReadResult(
333
+ AdsErrorCode(0), symbol_data)
311
334
 
312
335
  return output
313
336
 
@@ -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:
@@ -77,7 +77,7 @@ class AdsAddNotificationCommand(ICommand[AdsAddNotificationResponse]):
77
77
  Ads command to create a ads notification (subscription to a remote resource)
78
78
  """
79
79
 
80
- SERIALIZE_STRUCT_DEF: Final[Struct] = Struct("<IIIIIII16s")
80
+ SERIALIZE_STRUCT_DEF: Final[Struct] = Struct("<IIIIII16s")
81
81
 
82
82
  def __init__(
83
83
  self,
@@ -27,4 +27,7 @@ class AdsCommandError(Exception):
27
27
 
28
28
  def __repr__(self) -> str:
29
29
  """Returns a detailed representation of the error for debugging."""
30
- return f"AdsCommandError(error_code={self.error_code!r}, error_message:{self.error_code.description} args={self.args!r})"
30
+ return (
31
+ f"AdsCommandError(error_code={self.error_code!r}, "
32
+ f"error_message:{self.error_code.description} args={self.args!r})"
33
+ )
@@ -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,86 @@ 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")
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: #pylint: disable=try-except-raise
368
+ # Except everything... except a cancellation event
369
+ raise
370
+ except Exception as e:
371
+ self.logger.info("Connection lost: %s", e)
372
+
373
+ if self._state is ConnectionState.CLOSED:
374
+ break
375
+
376
+ self._state = ConnectionState.RECONNECTING
377
+ await self._teardown_stream(ConnectionError("Connection lost"))
378
+
379
+ while self._state is not ConnectionState.CLOSED:
380
+ try:
381
+ await self._open()
382
+ self.logger.info("Reconnected to the remote")
383
+ backoff = self.RECONNECT_INITIAL_BACKOFF
384
+ break
385
+ except asyncio.CancelledError: #pylint: disable=try-except-raise
386
+ raise
387
+ except Exception as e: # pylint: disable=broad-exception-caught
388
+ self.logger.info(
389
+ "Reconnect failed: %s, retrying in %.1fs", e, backoff
390
+ )
391
+ await asyncio.sleep(backoff + random.uniform(0, backoff))
392
+ backoff = min(backoff * 2, self.RECONNECT_MAX_BACKOFF)
393
+
394
+ async def _read_loop(self):
395
+ """
396
+ Continuously read and dispatch messages from the active stream.
397
+ Returns/raises when the connection drops; the supervisor handles recovery.
398
+ """
399
+ stream = self._stream
400
+ if stream is None:
401
+ raise ConnectionError("Not connected to the remote PLC")
295
402
 
296
- reader, _ = self._stream
297
- while True:
403
+ reader, _ = stream
404
+ while True:
405
+ try:
298
406
  # Read AmsTcpHeader
299
407
  tcp_header_bytes = await reader.readexactly(AmsTcpHeader.FIXED_SIZE)
300
408
  tcp_header = AmsTcpHeader.deserialize(tcp_header_bytes)
301
409
  payload_bytes = await reader.readexactly(tcp_header.length)
410
+ except asyncio.IncompleteReadError as e:
411
+ # readexactly hit EOF: the peer's recv() returned 0 bytes,
412
+ # i.e. the remote closed the connection. An empty partial is an
413
+ # orderly close on a message boundary; a non-empty partial means
414
+ # we were cut off mid-message. Either way the stream is dead, so
415
+ # raise a clear ConnectionError for the supervisor to reconnect.
416
+ if e.partial:
417
+ raise ConnectionError(
418
+ f"Connection closed mid-message: received "
419
+ f"{len(e.partial)} of {e.expected} expected bytes"
420
+ ) from e
421
+ raise ConnectionError("Connection closed by remote") from e
422
+
423
+ ams_message_stream = AdsStream(memoryview(payload_bytes))
424
+ ams_header = AmsHeader.deserialize(ams_message_stream)
425
+ ams_command = ams_message_stream.sub_stream(
426
+ ams_header.command_length)
302
427
 
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)
428
+ if AdsCommandState.ADS_RESPONSE in ams_header.command_flags:
429
+ await self._handle_response(ams_header, ams_command)
310
430
 
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)
431
+ elif AdsCommandState.ADS_REQUEST in ams_header.command_flags:
432
+ await self._handle_request(ams_header, ams_command)
318
433
 
319
434
 
320
435
  @dataclass
@@ -358,6 +473,7 @@ class AdsOverMqttTopics:
358
473
 
359
474
 
360
475
  class MqttBaseTransport(BaseTransport):
476
+ """Base transport that tunnels AMS/ADS frames over an MQTT broker."""
361
477
 
362
478
  REQUEST_TIMEOUT = 120.0
363
479
 
@@ -670,7 +786,7 @@ class AdsGMqttTransport(MqttBaseTransport, ITransport):
670
786
  response_future, timeout=self.REQUEST_TIMEOUT
671
787
  )
672
788
 
673
- async def _on_response(self, _client: gmqtt.Client, topic: str, payload: bytes, qos: int, properties: dict):
789
+ async def _on_response(self, _client: gmqtt.Client, topic: str, payload: bytes, _qos: int, _properties: dict):
674
790
  if self.topics.matches(self.topics.sub_response, topic):
675
791
  ams_message_stream = AdsStream(memoryview(payload))
676
792
  ams_header = AmsHeader.deserialize(ams_message_stream)