aioads 0.1.0.dev5__tar.gz → 0.1.0.dev7__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 (98) hide show
  1. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/PKG-INFO +1 -1
  2. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_sum_read.py +21 -16
  3. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_sum_read_write.py +15 -13
  4. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_datatype_by_name.py +8 -3
  5. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_datatype_upload.py +4 -1
  6. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_info_by_name_ex.py +30 -12
  7. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_table_version.py +6 -5
  8. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_upload.py +10 -4
  9. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_upload_info.py +10 -8
  10. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/transport.py +52 -2
  11. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/pyproject.toml +1 -1
  12. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_transport.py +69 -0
  13. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/.github/dependabot.yml +0 -0
  14. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/.github/workflows/ci.yml +0 -0
  15. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/.github/workflows/publish.yml +0 -0
  16. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/.gitignore +0 -0
  17. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/.vscode/settings.json +0 -0
  18. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/LICENSE +0 -0
  19. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/README.md +0 -0
  20. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ads_client.py +0 -0
  21. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ads_error_codes.py +0 -0
  22. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ads_notifications.py +0 -0
  23. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ads_symbol_cache.py +0 -0
  24. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ads_symbol_parser.py +0 -0
  25. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ams_address.py +0 -0
  26. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ams_header.py +0 -0
  27. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ams_service_port.py +0 -0
  28. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ams_tcp_header.py +0 -0
  29. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_add_notification.py +0 -0
  30. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_command.py +0 -0
  31. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_delete_notification.py +0 -0
  32. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_read.py +0 -0
  33. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_read_device_info.py +0 -0
  34. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_read_state.py +0 -0
  35. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_read_write.py +0 -0
  36. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_write.py +0 -0
  37. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_write_state.py +0 -0
  38. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/errors.py +0 -0
  39. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/errors.py +0 -0
  40. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_enable_route.py +0 -0
  41. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_function.py +0 -0
  42. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/py.typed +0 -0
  43. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/stream.py +0 -0
  44. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/utils/local_ip.py +0 -0
  45. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/docs/transmission_mode.md +0 -0
  46. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/docs/unittest_style_guide.html +0 -0
  47. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/examples/read_cmd_reuse_mqtt.py +0 -0
  48. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/examples/read_cycles.py +0 -0
  49. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/examples/read_cycles_mqtt.py +0 -0
  50. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/examples/read_multiple.py +0 -0
  51. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/examples/read_multiple_mqtt.py +0 -0
  52. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/examples/read_single.py +0 -0
  53. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/pdm.lock +0 -0
  54. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/__init__.py +0 -0
  55. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/builders.py +0 -0
  56. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/README.md +0 -0
  57. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/__init__.py +0 -0
  58. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/base.py +0 -0
  59. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/config.example.toml +0 -0
  60. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/config.py +0 -0
  61. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/test_connection.py +0 -0
  62. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/test_performance.py +0 -0
  63. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/test_read_symbols.py +0 -0
  64. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/__init__.py +0 -0
  65. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/__init__.py +0 -0
  66. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_add_notification.py +0 -0
  67. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_command.py +0 -0
  68. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_delete_notification.py +0 -0
  69. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_read.py +0 -0
  70. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_read_device_info.py +0 -0
  71. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_read_state.py +0 -0
  72. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_read_write.py +0 -0
  73. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_write.py +0 -0
  74. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_write_state.py +0 -0
  75. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_errors.py +0 -0
  76. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/__init__.py +0 -0
  77. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_enable_route.py +0 -0
  78. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_function.py +0 -0
  79. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_sum_read.py +0 -0
  80. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_sum_read_write.py +0 -0
  81. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_datatype_by_name.py +0 -0
  82. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_datatype_upload.py +0 -0
  83. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +0 -0
  84. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_table_version.py +0 -0
  85. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_upload.py +0 -0
  86. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_upload_info.py +0 -0
  87. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ads_client.py +0 -0
  88. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ads_error_codes.py +0 -0
  89. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ads_notifications.py +0 -0
  90. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ads_symbol_cache.py +0 -0
  91. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ads_symbol_parser.py +0 -0
  92. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ams_address.py +0 -0
  93. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ams_header.py +0 -0
  94. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ams_tcp_header.py +0 -0
  95. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_errors.py +0 -0
  96. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_stream.py +0 -0
  97. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/utils/__init__.py +0 -0
  98. {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/utils/test_local_ip.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aioads
3
- Version: 0.1.0.dev5
3
+ Version: 0.1.0.dev7
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
@@ -5,13 +5,13 @@ sending multiple `AdsReadCommand` in a single command
5
5
  https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_adsdll2/124830987.html&id=
6
6
  """
7
7
 
8
-
9
8
  from struct import Struct
10
9
  from typing import Final
11
10
  from aioads.ads_error_codes import AdsErrorCode
12
11
  from aioads.ams_address import AmsAddress
13
12
  from aioads.commands.ads_read import AdsReadCommand, AdsReadResponse
14
13
  from aioads.commands.ads_read_write import AdsReadWriteCommand
14
+ from aioads.commands.errors import AdsCommandError
15
15
  from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
16
16
  from aioads.stream import AdsStream
17
17
  from aioads.transport import ITransport
@@ -19,7 +19,7 @@ from aioads.transport import ITransport
19
19
 
20
20
  class AdsSumRead(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
21
21
  """
22
- Ads sum read command that allows batching up to 500 `AdsReadCommand` in a single call
22
+ Ads sum read command that allows batching up to 500 `AdsReadCommand` in a single call
23
23
  """
24
24
 
25
25
  ERROR_CODE_STRUCT_DEF: Final[Struct] = Struct("<I")
@@ -36,17 +36,15 @@ class AdsSumRead(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
36
36
 
37
37
  def serialize(self) -> bytes:
38
38
  """
39
- serialize all commands to bytes
39
+ serialize all commands to bytes
40
40
  """
41
41
  return b"".join(command.serialize() for command in self.commands)
42
42
 
43
43
  async def execute(self) -> list[tuple[AdsReadResponse, AdsStream]]:
44
44
  if len(self.commands) == 0:
45
- raise ValueError(
46
- "At least one command is required for ADS Sum Read")
45
+ raise ValueError("At least one command is required for ADS Sum Read")
47
46
  if len(self.commands) > 500:
48
- raise ValueError(
49
- "Too many commands for ADS Sum Read, maximum is 500")
47
+ raise ValueError("Too many commands for ADS Sum Read, maximum is 500")
50
48
 
51
49
  payload = self.serialize()
52
50
  command = AdsReadWriteCommand(
@@ -60,20 +58,27 @@ class AdsSumRead(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
60
58
  write_length=len(payload),
61
59
  write_data=payload,
62
60
  )
63
- _, read_payload = await command.request()
61
+ header, read_payload = await command.request()
62
+ if not header.error_code.ok:
63
+ raise AdsCommandError(header.error_code, "Failed to execute ADS Sum Read")
64
64
  error_stream = read_payload.sub_stream(
65
65
  # 4 bytes per error code
66
- len(self.commands) * self.ERROR_CODE_STRUCT_DEF.size)
66
+ len(self.commands)
67
+ * self.ERROR_CODE_STRUCT_DEF.size
68
+ )
67
69
 
68
70
  response: list[tuple[AdsReadResponse, AdsStream]] = []
69
71
  for cmd in self.commands:
70
72
  error_code = AdsErrorCode(
71
- error_stream.read_struct(self.ERROR_CODE_STRUCT_DEF)[0])
72
- response.append((
73
- AdsReadResponse(
74
- error_code=error_code,
75
- length=cmd.length,
76
- ),
77
- read_payload.sub_stream(cmd.length))
73
+ error_stream.read_struct(self.ERROR_CODE_STRUCT_DEF)[0]
74
+ )
75
+ response.append(
76
+ (
77
+ AdsReadResponse(
78
+ error_code=error_code,
79
+ length=cmd.length,
80
+ ),
81
+ read_payload.sub_stream(cmd.length),
82
+ )
78
83
  )
79
84
  return response
@@ -49,17 +49,17 @@ packet
49
49
  ```
50
50
 
51
51
  """
52
+
52
53
  from aioads.ams_address import AmsAddress
53
54
  from aioads.commands.ads_read import AdsReadResponse
54
55
  from aioads.commands.ads_read_write import AdsReadWriteCommand
56
+ from aioads.commands.errors import AdsCommandError
55
57
  from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
56
58
  from aioads.stream import AdsStream
57
59
  from aioads.transport import ITransport
58
60
 
59
61
 
60
- class AdsSumReadWrite(
61
- IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]
62
- ):
62
+ class AdsSumReadWrite(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
63
63
  """
64
64
  ADS Sum Read/Write function to send a READ/WRITE Command in batch.
65
65
  This function allows multiple read/write commands to be sent in a single request,
@@ -92,11 +92,9 @@ class AdsSumReadWrite(
92
92
  :return: An async generator yielding tuples of (AdsReadResponse, AdsStream) for each command.
93
93
  """
94
94
  if len(self.commands) == 0:
95
- raise ValueError(
96
- "At least one command is required for ADS Sum Read/Write")
95
+ raise ValueError("At least one command is required for ADS Sum Read/Write")
97
96
  if len(self.commands) > 500:
98
- raise ValueError(
99
- "Too many commands for ADS Sum Read/Write, maximum is 500")
97
+ raise ValueError("Too many commands for ADS Sum Read/Write, maximum is 500")
100
98
 
101
99
  payload = self.serialize()
102
100
  total_read_length = sum(
@@ -117,15 +115,19 @@ class AdsSumReadWrite(
117
115
  write_length=total_write_length,
118
116
  write_data=payload,
119
117
  )
120
- _, read_payload = await command.request()
118
+ header, read_payload = await command.request()
119
+ if not header.error_code.ok:
120
+ raise AdsCommandError(
121
+ header.error_code, "Failed to execute ADS Sum Read/Write"
122
+ )
121
123
  read_response_stream = read_payload.sub_stream(
122
- len(self.commands) * AdsReadResponse.STRUCT_DEF.size)
124
+ len(self.commands) * AdsReadResponse.STRUCT_DEF.size
125
+ )
123
126
 
124
127
  response: list[tuple[AdsReadResponse, AdsStream]] = []
125
128
  for _ in self.commands:
126
129
  read_response = AdsReadResponse.deserialize(read_response_stream)
127
- response.append((
128
- read_response,
129
- read_payload.sub_stream(read_response.length)
130
- ))
130
+ response.append(
131
+ (read_response, read_payload.sub_stream(read_response.length))
132
+ )
131
133
  return response
@@ -36,11 +36,13 @@ packet
36
36
 
37
37
 
38
38
  """
39
+
39
40
  from dataclasses import dataclass
40
41
  from struct import Struct
41
42
  from typing import ClassVar
42
43
  from aioads.ams_address import AmsAddress
43
44
  from aioads.commands.ads_read_write import AdsReadWriteCommand
45
+ from aioads.commands.errors import AdsCommandError
44
46
  from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
45
47
  from aioads.functions.ads_symbol_info_by_name_ex import (
46
48
  AdsSymbolDataType,
@@ -75,8 +77,7 @@ class AdsDatatypeArrayInfo:
75
77
  """
76
78
  Create `AdsDatatypeArrayInfo` from the `AdsStream`
77
79
  """
78
- l_bound, e_elements = data.read_struct(
79
- AdsDatatypeArrayInfo.STRUCT_DEF)
80
+ l_bound, e_elements = data.read_struct(AdsDatatypeArrayInfo.STRUCT_DEF)
80
81
  return AdsDatatypeArrayInfo(
81
82
  l_bound=l_bound,
82
83
  e_elements=e_elements,
@@ -227,6 +228,10 @@ class AdsSymbolDataTypeByName(IAdsFunction[SymbolDataTypeResponse]):
227
228
  write_length=len(payload),
228
229
  read_length=0xFFFF, # Max read length
229
230
  )
230
- _, read_payload = await command.request()
231
+ header, read_payload = await command.request()
232
+ if not header.error_code.ok:
233
+ raise AdsCommandError(
234
+ header.error_code, "Failed to read symbol datatype by name"
235
+ )
231
236
  symbol_datatype = SymbolDataTypeResponse.deserialize(read_payload)
232
237
  return symbol_datatype
@@ -6,6 +6,7 @@ https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_adsdll2/124
6
6
 
7
7
  from aioads.ams_address import AmsAddress
8
8
  from aioads.commands.ads_read import AdsReadCommand
9
+ from aioads.commands.errors import AdsCommandError
9
10
  from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
10
11
  from aioads.functions.ads_symbol_datatype_by_name import SymbolDataTypeResponse
11
12
  from aioads.transport import ITransport
@@ -38,7 +39,9 @@ class AdsSymbolDataTypeUpload(IAdsFunction[list[SymbolDataTypeResponse]]):
38
39
  idx_offset=0,
39
40
  length=self.dt_size,
40
41
  )
41
- _, read_payload = await command.request()
42
+ header, read_payload = await command.request()
43
+ if not header.error_code.ok:
44
+ raise AdsCommandError(header.error_code, "Failed to read datatype info")
42
45
 
43
46
  symbol_datatypes = list[SymbolDataTypeResponse]()
44
47
  start = read_payload.tell()
@@ -1,5 +1,5 @@
1
1
  """
2
- This module provides models and ads function to get the symbol info (symbol metadata) by the variable / symbol name.
2
+ This module provides models and ads function to get the symbol info (symbol metadata) by the variable / symbol name.
3
3
  """
4
4
 
5
5
  from dataclasses import dataclass
@@ -9,6 +9,7 @@ from typing import ClassVar
9
9
  from aioads.ads_error_codes import AdsErrorCode
10
10
  from aioads.ams_address import AmsAddress
11
11
  from aioads.commands.ads_read_write import AdsReadWriteCommand
12
+ from aioads.commands.errors import AdsCommandError
12
13
  from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
13
14
  from aioads.functions.ads_sum_read_write import AdsSumReadWrite
14
15
  from aioads.stream import AdsStream
@@ -113,10 +114,8 @@ class SymbolInfo:
113
114
  type_name_length,
114
115
  comment_length,
115
116
  ) = data.read_struct(SymbolInfo.FIXED_STRUCT)
116
- symbol_name = data.read(symbol_name_length + 1).rstrip(
117
- b"\x00").decode("cp1252")
118
- type_name = data.read(type_name_length + 1).rstrip(
119
- b"\x00").decode("cp1252")
117
+ symbol_name = data.read(symbol_name_length + 1).rstrip(b"\x00").decode("cp1252")
118
+ type_name = data.read(type_name_length + 1).rstrip(b"\x00").decode("cp1252")
120
119
  comment = data.read(comment_length + 1).rstrip(b"\x00").decode("cp1252")
121
120
 
122
121
  # Move the stream position to the end of the entry
@@ -133,6 +132,20 @@ class SymbolInfo:
133
132
  comment=comment,
134
133
  )
135
134
 
135
+ @classmethod
136
+ def void(cls) -> "SymbolInfo":
137
+ """A void symbol info that can be used as a placeholder"""
138
+ return cls(
139
+ idx_group=0,
140
+ idx_offset=0,
141
+ idx_length=0,
142
+ data_type=AdsSymbolDataType.VOID,
143
+ symbol_flags=AdsSymbolFlags(0),
144
+ symbol_name="",
145
+ type_name="",
146
+ comment="",
147
+ )
148
+
136
149
 
137
150
  class SymbolInfoByNameEx(IAdsFunction[SymbolInfo]):
138
151
  """
@@ -165,14 +178,14 @@ class SymbolInfoByNameEx(IAdsFunction[SymbolInfo]):
165
178
  read_length=0xFFFF, # Max read length
166
179
  write_data=payload,
167
180
  )
168
- _, read_payload = await command.request()
181
+ header, read_payload = await command.request()
182
+ if not header.error_code.ok:
183
+ raise AdsCommandError(header.error_code, "Failed to read symbol info")
169
184
  symbol_info = SymbolInfo.deserialize(read_payload)
170
185
  return symbol_info
171
186
 
172
187
 
173
- class SymbolInfoByNameExSumRead(
174
- IAdsFunction[list[tuple[AdsErrorCode, SymbolInfo]]]
175
- ):
188
+ class SymbolInfoByNameExSumRead(IAdsFunction[list[tuple[AdsErrorCode, SymbolInfo]]]):
176
189
  """
177
190
  This function utilizes the `AdsSumReadWrite` function to
178
191
  send multiple `SymbolInfoByNameEx` function calls in a batch.
@@ -219,9 +232,14 @@ class SymbolInfoByNameExSumRead(
219
232
  command = AdsSumReadWrite(
220
233
  transport=self.transport,
221
234
  ams_address=self.ams_address,
222
- commands=commands[batch_start:batch_start + self.BATCH_SIZE],
235
+ commands=commands[batch_start : batch_start + self.BATCH_SIZE],
223
236
  )
224
- for read_header, read_payload in await command.execute():
237
+ for header, read_payload in await command.execute():
238
+ # If we have an error and no payload we return a void symbol info
239
+ # we raise no exception to let the client handle the error for each symbol individually
240
+ if not header.error_code.ok and read_payload.length == 0:
241
+ response.append((header.error_code, SymbolInfo.void()))
242
+ continue
225
243
  symbol_info = SymbolInfo.deserialize(read_payload)
226
- response.append((read_header.error_code, symbol_info))
244
+ response.append((header.error_code, symbol_info))
227
245
  return response
@@ -2,7 +2,7 @@
2
2
  This module contains the ads function call for requesting the `SymbolTableVersion`.
3
3
 
4
4
 
5
- This can be used to monitoring, on change the index offsets in symbol metadata can change
5
+ This can be used to monitoring, on change the index offsets in symbol metadata can change
6
6
  and we need to re resolve the new address where we can read this symbol, if we don't do this
7
7
  we read a random memory array we most likely can't parse.
8
8
  https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_adsdll2/124830987.html&id=
@@ -42,8 +42,9 @@ class SymbolTableVersion(IAdsFunction[int]):
42
42
  idx_offset=0,
43
43
  length=1,
44
44
  )
45
- read_resp, read_stream = await command.request()
46
- if not read_resp.error_code.ok:
47
- raise AdsCommandError(read_resp.error_code,
48
- "Failed to read symbol table version")
45
+ header, read_stream = await command.request()
46
+ if not header.error_code.ok:
47
+ raise AdsCommandError(
48
+ header.error_code, "Failed to read symbol table version"
49
+ )
49
50
  return read_stream.read_struct(self.VERSION_STRUCT_DEF)[0]
@@ -1,9 +1,10 @@
1
1
  """
2
- This module contains a ads function to acquire the symbol infos (metadata) of the global / root symbols.
2
+ This module contains a ads function to acquire the symbol infos (metadata) of the global / root symbols.
3
3
  """
4
4
 
5
5
  from aioads.ams_address import AmsAddress
6
6
  from aioads.commands.ads_read import AdsReadCommand
7
+ from aioads.commands.errors import AdsCommandError
7
8
  from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
8
9
  from aioads.functions.ads_symbol_info_by_name_ex import SymbolInfo
9
10
  from aioads.transport import ITransport
@@ -11,8 +12,8 @@ from aioads.transport import ITransport
11
12
 
12
13
  class AdsSymbolUpload(IAdsFunction[list[SymbolInfo]]):
13
14
  """
14
- Ads Function to read all global symbols.
15
- Hint:
15
+ Ads Function to read all global symbols.
16
+ Hint:
16
17
  Only the top most symbol information are returned, to get the full ads tree
17
18
  you need to resolve it from this over the datatypes that can be requested with `AdsSymbolDataTypeUpload`
18
19
  """
@@ -36,7 +37,12 @@ class AdsSymbolUpload(IAdsFunction[list[SymbolInfo]]):
36
37
  idx_offset=0,
37
38
  length=self.tree_size,
38
39
  )
39
- _, read_payload = await command.request()
40
+ header, read_payload = await command.request()
41
+ if not header.error_code.ok:
42
+ raise AdsCommandError(
43
+ header.error_code, "Failed to request symbol upload info"
44
+ )
45
+
40
46
  symbol_infos = list[SymbolInfo]()
41
47
 
42
48
  start = read_payload.tell()
@@ -10,6 +10,7 @@ from typing import ClassVar
10
10
 
11
11
  from aioads.ams_address import AmsAddress
12
12
  from aioads.commands.ads_read import AdsReadCommand
13
+ from aioads.commands.errors import AdsCommandError
13
14
  from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
14
15
 
15
16
  from aioads.stream import AdsStream
@@ -46,11 +47,9 @@ class SymbolUploadInfo2Response:
46
47
  @classmethod
47
48
  def deserialize(cls, data: AdsStream) -> "SymbolUploadInfo2Response":
48
49
  """
49
- Create `SymbolUploadInfo2Response` from the `AdsStream`
50
+ Create `SymbolUploadInfo2Response` from the `AdsStream`
50
51
  """
51
- parsed: tuple[
52
- int, int, int, int, int, int
53
- ] = data.read_struct(cls.STRUCT_DEF)
52
+ parsed: tuple[int, int, int, int, int, int] = data.read_struct(cls.STRUCT_DEF)
54
53
  return cls(
55
54
  symbol_cnt=parsed[0],
56
55
  symbol_size=parsed[1],
@@ -63,7 +62,7 @@ class SymbolUploadInfo2Response:
63
62
 
64
63
  class AdsSymbolUploadInfo2(IAdsFunction[SymbolUploadInfo2Response]):
65
64
  """
66
- Function to acquire basic symbol information
65
+ Function to acquire basic symbol information
67
66
  """
68
67
 
69
68
  def __init__(self, transport: ITransport, ams_address: AmsAddress) -> None:
@@ -83,7 +82,10 @@ class AdsSymbolUploadInfo2(IAdsFunction[SymbolUploadInfo2Response]):
83
82
  # 6 x 4 bytes (We expect a struct with 6x UINT32)
84
83
  length=SymbolUploadInfo2Response.STRUCT_DEF.size,
85
84
  )
86
- _, read_payload = await command.request()
87
- symbol_upload_info = SymbolUploadInfo2Response.deserialize(
88
- read_payload)
85
+ header, read_payload = await command.request()
86
+ if not header.error_code.ok:
87
+ raise AdsCommandError(
88
+ header.error_code, "Failed to request symbol upload info"
89
+ )
90
+ symbol_upload_info = SymbolUploadInfo2Response.deserialize(read_payload)
89
91
  return symbol_upload_info
@@ -7,6 +7,7 @@ import enum
7
7
  import logging
8
8
  import random
9
9
  import re
10
+ import socket
10
11
  from abc import ABC
11
12
  from dataclasses import dataclass
12
13
  from dataclasses import field
@@ -211,6 +212,22 @@ class AdsTcpTransport(BaseTransport, ITransport):
211
212
  RECONNECT_INITIAL_BACKOFF = 1.0
212
213
  RECONNECT_MAX_BACKOFF = 30.0
213
214
 
215
+ # TCP keepalive / liveness tuning.
216
+ # Without these, a half-open connection (peer power-cut, cable pull, NAT
217
+ # idle-eviction) is never detected: no FIN/RST arrives, so the read loop
218
+ # blocks forever and the supervisor never reconnects. These force the kernel
219
+ # to probe an idle peer and to bound how long unacked writes wait, so a dead
220
+ # link surfaces as a socket error within ~KEEPALIVE_IDLE + KEEPALIVE_INTERVAL
221
+ # * KEEPALIVE_COUNT seconds.
222
+ KEEPALIVE_IDLE = 10
223
+ """Seconds of idle before the kernel starts sending keepalive probes (Linux: TCP_KEEPIDLE)."""
224
+ KEEPALIVE_INTERVAL = 3
225
+ """Seconds between keepalive probes (Linux: TCP_KEEPINTVL)."""
226
+ KEEPALIVE_COUNT = 3
227
+ """Unanswered probes before the connection is considered dead (Linux: TCP_KEEPCNT)."""
228
+ USER_TIMEOUT_MS = 15_000
229
+ """Milliseconds an unacked write may wait before the socket errors (Linux: TCP_USER_TIMEOUT)."""
230
+
214
231
  def __init__(
215
232
  self,
216
233
  src_address: AmsAddress,
@@ -279,10 +296,43 @@ class AdsTcpTransport(BaseTransport, ITransport):
279
296
  reader, writer = await asyncio.wait_for(
280
297
  asyncio.open_connection(self.ip, self.port), self.CONNECT_TIMEOUT
281
298
  )
299
+
300
+ self._enable_keepalive(writer)
282
301
  async with self._stream_lock:
283
302
  self._stream = (reader, writer)
284
303
  self._state = ConnectionState.CONNECTED
285
304
 
305
+ def _enable_keepalive(self, writer: asyncio.StreamWriter) -> None:
306
+ """
307
+ Enable TCP keepalive so a half-open connection is detected
308
+ even when no FIN/RST is ever sent.
309
+
310
+ The per-option settings beyond SO_KEEPALIVE are platform specific; each
311
+ is applied only when the running platform exposes it, so this stays a
312
+ no-op on systems that lack the knob rather than raising.
313
+ """
314
+
315
+ sock: socket.socket | None = writer.get_extra_info("socket")
316
+ if sock is None:
317
+ self.logger.warning(
318
+ "Failed to enable TCP keepalive: StreamWriter has no socket"
319
+ )
320
+ return
321
+
322
+ try:
323
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
324
+ for opt_name, value in (
325
+ ("TCP_KEEPIDLE", self.KEEPALIVE_IDLE),
326
+ ("TCP_KEEPINTVL", self.KEEPALIVE_INTERVAL),
327
+ ("TCP_KEEPCNT", self.KEEPALIVE_COUNT),
328
+ ("TCP_USER_TIMEOUT", self.USER_TIMEOUT_MS),
329
+ ):
330
+ opt = getattr(socket, opt_name, None)
331
+ if opt is not None:
332
+ sock.setsockopt(socket.IPPROTO_TCP, opt, value)
333
+ except OSError as e:
334
+ self.logger.warning("Failed to enable TCP keepalive: %s", e)
335
+
286
336
  async def _teardown_stream(self, ex: BaseException):
287
337
  """
288
338
  Fail all in-flight requests and close the current stream (if any).
@@ -364,7 +414,7 @@ class AdsTcpTransport(BaseTransport, ITransport):
364
414
  while self._state is not ConnectionState.CLOSED:
365
415
  try:
366
416
  await self._read_loop()
367
- except asyncio.CancelledError: #pylint: disable=try-except-raise
417
+ except asyncio.CancelledError: # pylint: disable=try-except-raise
368
418
  # Except everything... except a cancellation event
369
419
  raise
370
420
  except Exception as e:
@@ -382,7 +432,7 @@ class AdsTcpTransport(BaseTransport, ITransport):
382
432
  self.logger.info("Reconnected to the remote")
383
433
  backoff = self.RECONNECT_INITIAL_BACKOFF
384
434
  break
385
- except asyncio.CancelledError: #pylint: disable=try-except-raise
435
+ except asyncio.CancelledError: # pylint: disable=try-except-raise
386
436
  raise
387
437
  except Exception as e: # pylint: disable=broad-exception-caught
388
438
  self.logger.info(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aioads"
3
- version = "0.1.0.dev5"
3
+ version = "0.1.0.dev7"
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" },
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ import socket
5
6
  import unittest
6
7
  from unittest.mock import Mock
7
8
 
@@ -216,5 +217,73 @@ class TestAdsTcpTransport(unittest.IsolatedAsyncioTestCase):
216
217
  self.assertIsInstance(ctx.exception.__cause__, asyncio.IncompleteReadError)
217
218
 
218
219
 
220
+ class TestAdsTcpTransportKeepalive(unittest.TestCase):
221
+ """TCP keepalive / liveness configuration applied to the active socket."""
222
+
223
+ def setUp(self) -> None:
224
+ self.transport = AdsTcpTransport(
225
+ src_address=make_ams_address(), ip="127.0.0.1"
226
+ )
227
+ # A real AF_INET TCP socket whose options we can read back. It does not
228
+ # need to be connected to accept these socket options.
229
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
230
+ self.addCleanup(self.sock.close)
231
+ # A StreamWriter exposes its underlying socket via get_extra_info("socket").
232
+ self.writer = Mock()
233
+ self.writer.get_extra_info.return_value = self.sock
234
+
235
+ def test_enable_keepalive_turns_on_so_keepalive(self) -> None:
236
+ # Act
237
+ self.transport._enable_keepalive(self.writer)
238
+
239
+ # Assert
240
+ self.assertEqual(
241
+ self.sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE), 1
242
+ )
243
+
244
+ @unittest.skipUnless(
245
+ hasattr(socket, "TCP_KEEPIDLE"), "platform lacks TCP keepalive tuning"
246
+ )
247
+ def test_enable_keepalive_applies_configured_tuning_values(self) -> None:
248
+ # Act
249
+ self.transport._enable_keepalive(self.writer)
250
+
251
+ # Assert — each kernel knob reflects the transport's configured constant
252
+ expected = [
253
+ ("TCP_KEEPIDLE", AdsTcpTransport.KEEPALIVE_IDLE),
254
+ ("TCP_KEEPINTVL", AdsTcpTransport.KEEPALIVE_INTERVAL),
255
+ ("TCP_KEEPCNT", AdsTcpTransport.KEEPALIVE_COUNT),
256
+ ("TCP_USER_TIMEOUT", AdsTcpTransport.USER_TIMEOUT_MS),
257
+ ]
258
+ for opt_name, value in expected:
259
+ opt = getattr(socket, opt_name, None)
260
+ if opt is None:
261
+ continue
262
+ with self.subTest(option=opt_name):
263
+ self.assertEqual(
264
+ self.sock.getsockopt(socket.IPPROTO_TCP, opt), value
265
+ )
266
+
267
+ def test_enable_keepalive_without_socket_does_not_raise(self) -> None:
268
+ # Arrange — a StreamWriter without an underlying socket
269
+ self.writer.get_extra_info.return_value = None
270
+
271
+ # Act / Assert — keepalive is best-effort, so it is skipped quietly
272
+ try:
273
+ self.transport._enable_keepalive(self.writer)
274
+ except Exception as e: # pragma: no cover - failure path
275
+ self.fail(f"_enable_keepalive must not raise when no socket: {e}")
276
+
277
+ def test_enable_keepalive_on_closed_socket_does_not_raise(self) -> None:
278
+ # Arrange — a closed socket makes setsockopt fail with OSError
279
+ self.sock.close()
280
+
281
+ # Act / Assert — keepalive is best-effort, so the error is swallowed
282
+ try:
283
+ self.transport._enable_keepalive(self.writer)
284
+ except OSError:
285
+ self.fail("_enable_keepalive must not propagate socket errors")
286
+
287
+
219
288
  if __name__ == "__main__":
220
289
  unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes