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.
- aioads-0.1.0.dev4/.github/dependabot.yml +20 -0
- aioads-0.1.0.dev4/.github/workflows/ci.yml +51 -0
- aioads-0.1.0.dev4/.github/workflows/publish.yml +62 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/PKG-INFO +1 -1
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ads_client.py +43 -20
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ads_symbol_parser.py +2 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_add_notification.py +1 -1
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/errors.py +4 -1
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/transport.py +158 -42
- aioads-0.1.0.dev4/docs/unittest_style_guide.html +1739 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/pyproject.toml +6 -1
- aioads-0.1.0.dev4/tests/builders.py +63 -0
- aioads-0.1.0.dev4/tests/unit/__init__.py +0 -0
- aioads-0.1.0.dev4/tests/unit/commands/__init__.py +0 -0
- aioads-0.1.0.dev4/tests/unit/commands/test_ads_add_notification.py +131 -0
- aioads-0.1.0.dev4/tests/unit/commands/test_ads_command.py +63 -0
- aioads-0.1.0.dev4/tests/unit/commands/test_ads_delete_notification.py +83 -0
- aioads-0.1.0.dev4/tests/unit/commands/test_ads_read.py +124 -0
- aioads-0.1.0.dev4/tests/unit/commands/test_ads_read_device_info.py +147 -0
- aioads-0.1.0.dev4/tests/unit/commands/test_ads_read_state.py +137 -0
- aioads-0.1.0.dev4/tests/unit/commands/test_ads_read_write.py +98 -0
- aioads-0.1.0.dev4/tests/unit/commands/test_ads_write.py +117 -0
- aioads-0.1.0.dev4/tests/unit/commands/test_ads_write_state.py +85 -0
- aioads-0.1.0.dev4/tests/unit/commands/test_errors.py +50 -0
- aioads-0.1.0.dev4/tests/unit/functions/__init__.py +0 -0
- aioads-0.1.0.dev4/tests/unit/functions/test_ads_enable_route.py +71 -0
- aioads-0.1.0.dev4/tests/unit/functions/test_ads_function.py +32 -0
- aioads-0.1.0.dev4/tests/unit/functions/test_ads_sum_read.py +102 -0
- aioads-0.1.0.dev4/tests/unit/functions/test_ads_sum_read_write.py +96 -0
- aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_datatype_by_name.py +137 -0
- aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_datatype_upload.py +82 -0
- aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +150 -0
- aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_table_version.py +74 -0
- aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_upload.py +78 -0
- aioads-0.1.0.dev4/tests/unit/functions/test_ads_symbol_upload_info.py +77 -0
- aioads-0.1.0.dev4/tests/unit/test_ads_client.py +184 -0
- aioads-0.1.0.dev4/tests/unit/test_ads_error_codes.py +77 -0
- aioads-0.1.0.dev4/tests/unit/test_ads_notifications.py +161 -0
- aioads-0.1.0.dev4/tests/unit/test_ads_symbol_cache.py +182 -0
- aioads-0.1.0.dev4/tests/unit/test_ads_symbol_parser.py +244 -0
- aioads-0.1.0.dev4/tests/unit/test_ams_address.py +61 -0
- aioads-0.1.0.dev4/tests/unit/test_ams_header.py +84 -0
- aioads-0.1.0.dev4/tests/unit/test_ams_tcp_header.py +62 -0
- aioads-0.1.0.dev4/tests/unit/test_errors.py +51 -0
- aioads-0.1.0.dev4/tests/unit/test_stream.py +133 -0
- aioads-0.1.0.dev4/tests/unit/test_transport.py +220 -0
- aioads-0.1.0.dev4/tests/unit/utils/__init__.py +0 -0
- aioads-0.1.0.dev4/tests/unit/utils/test_local_ip.py +60 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/.gitignore +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/.vscode/settings.json +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/LICENSE +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/README.md +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ads_error_codes.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ads_notifications.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ads_symbol_cache.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ams_address.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ams_header.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/ams_tcp_header.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_command.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_delete_notification.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_read.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_read_device_info.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_read_state.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_read_write.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_write.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/commands/ads_write_state.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/errors.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_enable_route.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_function.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_sum_read.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_sum_read_write.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_info_by_name_ex.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_table_version.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_upload.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/functions/ads_symbol_upload_info.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/stream.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/aioads/utils/local_ip.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/docs/transmission_mode.md +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/examples/read_cmd_reuse_mqtt.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/examples/read_cycles.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/examples/read_cycles_mqtt.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/examples/read_multiple.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/examples/read_multiple_mqtt.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/examples/read_single.py +0 -0
- {aioads-0.1.0.dev2 → aioads-0.1.0.dev4}/pdm.lock +0 -0
- {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
|
|
@@ -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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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=
|
|
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 (
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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("<
|
|
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
|
|
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.
|
|
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,86 @@ 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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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,
|
|
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)
|