aioads 0.1.0.dev9__tar.gz → 0.1.0.dev11__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 (99) hide show
  1. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/PKG-INFO +1 -1
  2. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/ads_client.py +45 -7
  3. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/ads_symbol_cache.py +3 -0
  4. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/functions/ads_sum_read.py +33 -14
  5. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/functions/ads_sum_read_write.py +32 -16
  6. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/functions/ads_symbol_info_by_name_ex.py +30 -24
  7. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/pyproject.toml +1 -1
  8. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/integration/test_memory.py +31 -9
  9. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/functions/test_ads_sum_read.py +47 -10
  10. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/functions/test_ads_sum_read_write.py +48 -10
  11. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +1 -0
  12. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/test_ads_client.py +2 -1
  13. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/test_ads_notifications.py +1 -1
  14. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/test_ads_symbol_cache.py +7 -2
  15. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/.github/dependabot.yml +0 -0
  16. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/.github/workflows/ci.yml +0 -0
  17. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/.github/workflows/publish.yml +0 -0
  18. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/.gitignore +0 -0
  19. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/.vscode/settings.json +0 -0
  20. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/LICENSE +0 -0
  21. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/README.md +0 -0
  22. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/ads_error_codes.py +0 -0
  23. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/ads_notifications.py +0 -0
  24. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/ads_symbol_parser.py +0 -0
  25. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/ams_address.py +0 -0
  26. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/ams_header.py +0 -0
  27. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/ams_service_port.py +0 -0
  28. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/ams_tcp_header.py +0 -0
  29. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/commands/ads_add_notification.py +0 -0
  30. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/commands/ads_command.py +0 -0
  31. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/commands/ads_delete_notification.py +0 -0
  32. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/commands/ads_read.py +0 -0
  33. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/commands/ads_read_device_info.py +0 -0
  34. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/commands/ads_read_state.py +0 -0
  35. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/commands/ads_read_write.py +0 -0
  36. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/commands/ads_write.py +0 -0
  37. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/commands/ads_write_state.py +0 -0
  38. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/commands/errors.py +0 -0
  39. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/errors.py +0 -0
  40. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/functions/ads_enable_route.py +0 -0
  41. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/functions/ads_function.py +0 -0
  42. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
  43. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
  44. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/functions/ads_symbol_table_version.py +0 -0
  45. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/functions/ads_symbol_upload.py +0 -0
  46. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/functions/ads_symbol_upload_info.py +0 -0
  47. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/py.typed +0 -0
  48. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/stream.py +0 -0
  49. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/transport.py +0 -0
  50. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/aioads/utils/local_ip.py +0 -0
  51. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/docs/transmission_mode.md +0 -0
  52. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/docs/unittest_style_guide.html +0 -0
  53. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/examples/read_cmd_reuse_mqtt.py +0 -0
  54. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/examples/read_cycles.py +0 -0
  55. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/examples/read_cycles_mqtt.py +0 -0
  56. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/examples/read_multiple.py +0 -0
  57. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/examples/read_multiple_mqtt.py +0 -0
  58. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/examples/read_single.py +0 -0
  59. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/pdm.lock +0 -0
  60. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/__init__.py +0 -0
  61. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/builders.py +0 -0
  62. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/integration/README.md +0 -0
  63. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/integration/__init__.py +0 -0
  64. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/integration/base.py +0 -0
  65. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/integration/config.example.toml +0 -0
  66. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/integration/config.py +0 -0
  67. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/integration/test_connection.py +0 -0
  68. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/integration/test_performance.py +0 -0
  69. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/integration/test_read_symbols.py +0 -0
  70. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/__init__.py +0 -0
  71. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/commands/__init__.py +0 -0
  72. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/commands/test_ads_add_notification.py +0 -0
  73. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/commands/test_ads_command.py +0 -0
  74. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/commands/test_ads_delete_notification.py +0 -0
  75. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/commands/test_ads_read.py +0 -0
  76. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/commands/test_ads_read_device_info.py +0 -0
  77. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/commands/test_ads_read_state.py +0 -0
  78. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/commands/test_ads_read_write.py +0 -0
  79. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/commands/test_ads_write.py +0 -0
  80. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/commands/test_ads_write_state.py +0 -0
  81. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/commands/test_errors.py +0 -0
  82. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/functions/__init__.py +0 -0
  83. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/functions/test_ads_enable_route.py +0 -0
  84. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/functions/test_ads_function.py +0 -0
  85. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/functions/test_ads_symbol_datatype_by_name.py +0 -0
  86. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/functions/test_ads_symbol_datatype_upload.py +0 -0
  87. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/functions/test_ads_symbol_table_version.py +0 -0
  88. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/functions/test_ads_symbol_upload.py +0 -0
  89. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/functions/test_ads_symbol_upload_info.py +0 -0
  90. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/test_ads_error_codes.py +0 -0
  91. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/test_ads_symbol_parser.py +0 -0
  92. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/test_ams_address.py +0 -0
  93. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/test_ams_header.py +0 -0
  94. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/test_ams_tcp_header.py +0 -0
  95. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/test_errors.py +0 -0
  96. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/test_stream.py +0 -0
  97. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/test_transport.py +0 -0
  98. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/tests/unit/utils/__init__.py +0 -0
  99. {aioads-0.1.0.dev9 → aioads-0.1.0.dev11}/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.dev9
3
+ Version: 0.1.0.dev11
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
@@ -1,5 +1,6 @@
1
1
  """ADS Client for communicating with ADS devices asynchronously."""
2
2
 
3
+ import asyncio
3
4
  import logging
4
5
  from contextlib import asynccontextmanager
5
6
  from dataclasses import dataclass
@@ -14,6 +15,7 @@ from aioads.ads_symbol_parser import ISymbolParser
14
15
  from aioads.ams_address import AmsAddress
15
16
  from aioads.commands.ads_add_notification import TransmissionMode
16
17
  from aioads.commands.ads_read import AdsReadCommand
18
+ from aioads.commands.ads_read import AdsReadResponse
17
19
  from aioads.commands.ads_read_state import ReadStateCommand
18
20
  from aioads.commands.errors import AdsCommandError
19
21
  from aioads.functions.ads_enable_route import AdsEnableRoute
@@ -24,6 +26,7 @@ from aioads.functions.ads_symbol_datatype_upload import AdsSymbolDataTypeUpload
24
26
  from aioads.functions.ads_symbol_info_by_name_ex import SymbolInfo
25
27
  from aioads.functions.ads_symbol_upload import AdsSymbolUpload
26
28
  from aioads.functions.ads_symbol_upload_info import AdsSymbolUploadInfo2
29
+ from aioads.stream import AdsStream
27
30
  from aioads.transport import AdsTcpTransport
28
31
  from aioads.transport import ITransport
29
32
 
@@ -50,17 +53,24 @@ class AdsClient:
50
53
  parser: ISymbolParser,
51
54
  cache: AdsSymbolCache,
52
55
  notification: NotificationManager,
56
+ sum_batch_size: int,
53
57
  ) -> None:
54
58
  self.logger = logging.getLogger(f"{__name__}.'{dst_address.net_id}'")
55
59
  self.transport = transport
56
60
  self.dst_address = dst_address
57
61
  self.parser = parser
62
+ self.sum_batch_size = sum_batch_size
58
63
  self._cache = cache
59
64
  self._notification = notification
60
65
 
61
66
  @classmethod
62
67
  def create_tcp(
63
- cls, src: AmsAddress, dst: AmsAddress, ip: str, port: int = 48898
68
+ cls,
69
+ src: AmsAddress,
70
+ dst: AmsAddress,
71
+ ip: str,
72
+ port: int = 48898,
73
+ sum_batch_size: int = 500,
64
74
  ) -> "AdsClient":
65
75
  """
66
76
  Create a new ADS client with TCP transport.
@@ -68,11 +78,14 @@ class AdsClient:
68
78
  :param dst: The destination AMS address
69
79
  :param ip: The target IP address
70
80
  :param port: The target port
81
+ :param sum_batch_size: Maximum commands per ADS sum command (1..500)
71
82
  :return: An instance of AdsClient
72
83
  """
73
84
  parser = AdsSymbolParser([])
74
85
  transport = AdsTcpTransport(src_address=src, ip=ip, port=port)
75
- cache = AdsSymbolCache(transport=transport, dst_address=dst)
86
+ cache = AdsSymbolCache(
87
+ transport=transport, dst_address=dst, batch_size=sum_batch_size
88
+ )
76
89
  notification_manager = NotificationManager(
77
90
  transport=transport,
78
91
  dst_address=dst,
@@ -85,21 +98,28 @@ class AdsClient:
85
98
  dst_address=dst,
86
99
  cache=cache,
87
100
  notification=notification_manager,
101
+ sum_batch_size=sum_batch_size,
88
102
  )
89
103
 
90
104
  @classmethod
91
105
  def create_from_transport(
92
- cls, dst: AmsAddress, transport: ITransport
106
+ cls,
107
+ dst: AmsAddress,
108
+ transport: ITransport,
109
+ sum_batch_size: int = 500,
93
110
  ) -> "AdsClient":
94
111
  """
95
112
  Create a new ADS client with an existing transport instance.
96
113
  :param src: The source AMS address
97
114
  :param dst: The destination AMS address
98
115
  :param transport: The existing transport instance
116
+ :param sum_batch_size: Maximum commands per ADS sum command (1..500)
99
117
  :return: An instance of AdsClient
100
118
  """
101
119
  parser = AdsSymbolParser([])
102
- cache = AdsSymbolCache(transport=transport, dst_address=dst)
120
+ cache = AdsSymbolCache(
121
+ transport=transport, dst_address=dst, batch_size=sum_batch_size
122
+ )
103
123
  notification_manager = NotificationManager(
104
124
  transport=transport,
105
125
  dst_address=dst,
@@ -112,6 +132,7 @@ class AdsClient:
112
132
  dst_address=dst,
113
133
  cache=cache,
114
134
  notification=notification_manager,
135
+ sum_batch_size=sum_batch_size,
115
136
  )
116
137
 
117
138
  async def connect(self) -> None:
@@ -255,7 +276,8 @@ class AdsClient:
255
276
  exceptions.append(AdsCommandError(error_code, symbol_path))
256
277
 
257
278
  if exceptions:
258
- raise ExceptionGroup("One or more symbol read errors occurred", exceptions)
279
+ raise ExceptionGroup(
280
+ "One or more symbol read errors occurred", exceptions)
259
281
 
260
282
  async def read_symbols_by_names(
261
283
  self, symbol_names: set[str], raise_errors: bool = True
@@ -273,7 +295,7 @@ class AdsClient:
273
295
  # Pre-seed the result with lookup errors so the output preserves the
274
296
  # requested order; successful entries are overwritten after the read.
275
297
  # Symbols with a failed lookup cannot be read: their idx_group/offset
276
- # would misalign the bulk response.
298
+ # would misaligne the bulk response.
277
299
  output: dict[str, SymbolReadResult] = {
278
300
  name: SymbolReadResult(error_code, None)
279
301
  for name, (error_code, _) in symbol_infos.items()
@@ -299,9 +321,26 @@ class AdsClient:
299
321
  )
300
322
  for _, info in readable
301
323
  ],
324
+ batch_size=self.sum_batch_size,
302
325
  )
303
326
  response = await sum_read.execute()
304
327
 
328
+ # Parsing is pure-Python and O(total payload size); run it off the
329
+ # event loop so large bulk reads don't block other tasks.
330
+ parsed = await asyncio.to_thread(
331
+ self._parse_sum_read_response, readable, response)
332
+ output.update(parsed)
333
+ return output
334
+
335
+ def _parse_sum_read_response(
336
+ self,
337
+ readable: list[tuple[str, SymbolInfo]],
338
+ response: list[tuple[AdsReadResponse, AdsStream]],
339
+ ) -> dict[str, SymbolReadResult]:
340
+ """
341
+ Parse the payloads of a bulk read into symbol values.
342
+ """
343
+ output: dict[str, SymbolReadResult] = {}
305
344
  for (name, info), (read_response, resp_payload) in zip(
306
345
  readable, response, strict=True
307
346
  ):
@@ -316,7 +355,6 @@ class AdsClient:
316
355
  raw_data=resp_payload,
317
356
  ),
318
357
  )
319
-
320
358
  return output
321
359
 
322
360
  @asynccontextmanager
@@ -29,12 +29,14 @@ class AdsSymbolCache:
29
29
  self,
30
30
  transport: ITransport,
31
31
  dst_address: AmsAddress,
32
+ batch_size: int,
32
33
  ttl_seconds: int = 21600,
33
34
  ) -> None:
34
35
  self._logger = logging.getLogger(f"{__name__}.'{dst_address.net_id}'")
35
36
  self._transport = transport
36
37
  self._dst_address = dst_address
37
38
  self._ttl_seconds = ttl_seconds
39
+ self._batch_size = batch_size
38
40
  self._cache: dict[str, tuple[int, AdsErrorCode, SymbolInfo]] = {}
39
41
  self._cache_monitor_task: asyncio.Task[None] | None = None
40
42
 
@@ -175,6 +177,7 @@ class AdsSymbolCache:
175
177
  transport=self._transport,
176
178
  ams_address=self._dst_address,
177
179
  symbol_names=request_list,
180
+ batch_size=self._batch_size,
178
181
  )
179
182
  response = await function.execute()
180
183
  for (error, symbol_info), symbol_name in zip(
@@ -12,14 +12,19 @@ from aioads.ams_address import AmsAddress
12
12
  from aioads.commands.ads_read import AdsReadCommand, AdsReadResponse
13
13
  from aioads.commands.ads_read_write import AdsReadWriteCommand
14
14
  from aioads.commands.errors import AdsCommandError
15
- from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
15
+ from aioads.functions.ads_function import (
16
+ AdsFunctionSymbolGroup,
17
+ IAdsFunction,
18
+ )
16
19
  from aioads.stream import AdsStream
17
20
  from aioads.transport import ITransport
18
21
 
19
22
 
20
23
  class AdsSumRead(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
21
24
  """
22
- Ads sum read command that allows batching up to 500 `AdsReadCommand` in a single call
25
+ Ads sum read command that batches `AdsReadCommand` calls.
26
+ The PLC accepts at most 500 commands per sum command; larger command lists
27
+ are transparently split into multiple sum commands of at most `batch_size`.
23
28
  """
24
29
 
25
30
  ERROR_CODE_STRUCT_DEF: Final[Struct] = Struct("<I")
@@ -29,46 +34,60 @@ class AdsSumRead(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
29
34
  transport: ITransport,
30
35
  ams_address: AmsAddress,
31
36
  commands: list[AdsReadCommand],
37
+ batch_size: int,
32
38
  ) -> None:
33
39
  self.transport = transport
34
40
  self.ams_address = ams_address
35
41
  self.commands = commands
42
+ self.batch_size = batch_size
36
43
 
37
- def serialize(self) -> bytes:
44
+ def serialize(self, commands: list[AdsReadCommand]) -> bytes:
38
45
  """
39
- serialize all commands to bytes
46
+ serialize the given commands to bytes
40
47
  """
41
- return b"".join(command.serialize() for command in self.commands)
48
+ return b"".join(command.serialize() for command in commands)
42
49
 
43
50
  async def execute(self) -> list[tuple[AdsReadResponse, AdsStream]]:
44
51
  if len(self.commands) == 0:
45
- raise ValueError("At least one command is required for ADS Sum Read")
46
- if len(self.commands) > 500:
47
- raise ValueError("Too many commands for ADS Sum Read, maximum is 500")
52
+ raise ValueError(
53
+ "At least one command is required for ADS Sum Read")
48
54
 
49
- payload = self.serialize()
55
+ if self.batch_size <= 0 or self.batch_size > 500:
56
+ raise ValueError("Batch size must be between 1 and 500")
57
+
58
+ response: list[tuple[AdsReadResponse, AdsStream]] = []
59
+ for batch_start in range(0, len(self.commands), self.batch_size):
60
+ batch = self.commands[batch_start: batch_start + self.batch_size]
61
+ response.extend(await self._execute_batch(batch))
62
+ return response
63
+
64
+ async def _execute_batch(
65
+ self, commands: list[AdsReadCommand]
66
+ ) -> list[tuple[AdsReadResponse, AdsStream]]:
67
+ payload = self.serialize(commands)
50
68
  command = AdsReadWriteCommand(
51
69
  transport=self.transport,
52
70
  ams_address=self.ams_address,
53
71
  idx_group=AdsFunctionSymbolGroup.ADSIGRP_SUM_READ,
54
- idx_offset=len(self.commands),
72
+ idx_offset=len(commands),
55
73
  read_length=sum(
56
- cmd.length + 4 for cmd in self.commands
74
+ cmd.length + 4 for cmd in commands
57
75
  ), # +4 for the error code per command
58
76
  write_length=len(payload),
59
77
  write_data=payload,
60
78
  )
61
79
  header, read_payload = await command.request()
62
80
  if not header.error_code.ok:
63
- raise AdsCommandError(header.error_code, "Failed to execute ADS Sum Read")
81
+ raise AdsCommandError(
82
+ header.error_code, "Failed to execute ADS Sum Read")
64
83
  error_stream = read_payload.sub_stream(
65
84
  # 4 bytes per error code
66
- len(self.commands)
85
+ len(commands)
67
86
  * self.ERROR_CODE_STRUCT_DEF.size
68
87
  )
69
88
 
70
89
  response: list[tuple[AdsReadResponse, AdsStream]] = []
71
- for cmd in self.commands:
90
+ for cmd in commands:
72
91
  error_code = AdsErrorCode(
73
92
  error_stream.read_struct(self.ERROR_CODE_STRUCT_DEF)[0]
74
93
  )
@@ -54,7 +54,10 @@ from aioads.ams_address import AmsAddress
54
54
  from aioads.commands.ads_read import AdsReadResponse
55
55
  from aioads.commands.ads_read_write import AdsReadWriteCommand
56
56
  from aioads.commands.errors import AdsCommandError
57
- from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
57
+ from aioads.functions.ads_function import (
58
+ AdsFunctionSymbolGroup,
59
+ IAdsFunction,
60
+ )
58
61
  from aioads.stream import AdsStream
59
62
  from aioads.transport import ITransport
60
63
 
@@ -64,6 +67,8 @@ class AdsSumReadWrite(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
64
67
  ADS Sum Read/Write function to send a READ/WRITE Command in batch.
65
68
  This function allows multiple read/write commands to be sent in a single request,
66
69
  reducing communication overhead.
70
+ The PLC accepts at most 500 commands per sum command; larger command lists
71
+ are transparently split into multiple sum commands of at most `batch_size`.
67
72
  """
68
73
 
69
74
  def __init__(
@@ -71,37 +76,48 @@ class AdsSumReadWrite(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
71
76
  transport: ITransport,
72
77
  ams_address: AmsAddress,
73
78
  commands: list[AdsReadWriteCommand],
79
+ batch_size: int,
74
80
  ) -> None:
75
81
  self.transport = transport
76
82
  self.ams_address = ams_address
77
83
  self.commands = commands
84
+ self.batch_size = batch_size
78
85
 
79
- def serialize(self) -> bytes:
86
+ def serialize(self, commands: list[AdsReadWriteCommand]) -> bytes:
80
87
  """
81
- Serialize the ADS Sum Read/Write commands into bytes.
88
+ Serialize the given ADS Sum Read/Write commands into bytes.
82
89
  :return: Serialized bytes of the sum read/write command.
83
90
  """
84
- data = b"".join(cmd.serialize_header() for cmd in self.commands)
85
- data += b"".join(cmd.payload for cmd in self.commands)
91
+ data = b"".join(cmd.serialize_header() for cmd in commands)
92
+ data += b"".join(cmd.payload for cmd in commands)
86
93
  return data
87
94
 
88
95
  async def execute(self) -> list[tuple[AdsReadResponse, AdsStream]]:
89
96
  """
90
97
  Request execution of the ADS Sum Read/Write function.
91
- Returns an async generator yielding the responses for each command in the batch.
92
- :return: An async generator yielding tuples of (AdsReadResponse, AdsStream) for each command.
98
+ :return: A list of tuples of (AdsReadResponse, AdsStream) for each command.
93
99
  """
94
100
  if len(self.commands) == 0:
95
- raise ValueError("At least one command is required for ADS Sum Read/Write")
96
- if len(self.commands) > 500:
97
- raise ValueError("Too many commands for ADS Sum Read/Write, maximum is 500")
101
+ raise ValueError(
102
+ "At least one command is required for ADS Sum Read/Write")
103
+ if self.batch_size <= 0 or self.batch_size > 500:
104
+ raise ValueError("Batch size must be between 1 and 500")
98
105
 
99
- payload = self.serialize()
106
+ response: list[tuple[AdsReadResponse, AdsStream]] = []
107
+ for batch_start in range(0, len(self.commands), self.batch_size):
108
+ batch = self.commands[batch_start: batch_start + self.batch_size]
109
+ response.extend(await self._execute_batch(batch))
110
+ return response
111
+
112
+ async def _execute_batch(
113
+ self, commands: list[AdsReadWriteCommand]
114
+ ) -> list[tuple[AdsReadResponse, AdsStream]]:
115
+ payload = self.serialize(commands)
100
116
  total_read_length = sum(
101
- cmd.read_length + 8 for cmd in self.commands
117
+ cmd.read_length + 8 for cmd in commands
102
118
  ) # 8 bytes for each error code
103
119
  total_write_length = sum(
104
- cmd.write_length + (4 * 4) for cmd in self.commands
120
+ cmd.write_length + (4 * 4) for cmd in commands
105
121
  ) # 8 Bytes for the fixed header
106
122
  assert total_write_length == len(
107
123
  payload
@@ -110,7 +126,7 @@ class AdsSumReadWrite(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
110
126
  transport=self.transport,
111
127
  ams_address=self.ams_address,
112
128
  idx_group=AdsFunctionSymbolGroup.ADSIGRP_SUM_READ_WRITE,
113
- idx_offset=len(self.commands),
129
+ idx_offset=len(commands),
114
130
  read_length=total_read_length,
115
131
  write_length=total_write_length,
116
132
  write_data=payload,
@@ -121,11 +137,11 @@ class AdsSumReadWrite(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
121
137
  header.error_code, "Failed to execute ADS Sum Read/Write"
122
138
  )
123
139
  read_response_stream = read_payload.sub_stream(
124
- len(self.commands) * AdsReadResponse.STRUCT_DEF.size
140
+ len(commands) * AdsReadResponse.STRUCT_DEF.size
125
141
  )
126
142
 
127
143
  response: list[tuple[AdsReadResponse, AdsStream]] = []
128
- for _ in self.commands:
144
+ for _ in commands:
129
145
  read_response = AdsReadResponse.deserialize(read_response_stream)
130
146
  response.append(
131
147
  (read_response, read_payload.sub_stream(read_response.length))
@@ -10,7 +10,10 @@ 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
12
  from aioads.commands.errors import AdsCommandError
13
- from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
13
+ from aioads.functions.ads_function import (
14
+ AdsFunctionSymbolGroup,
15
+ IAdsFunction,
16
+ )
14
17
  from aioads.functions.ads_sum_read_write import AdsSumReadWrite
15
18
  from aioads.stream import AdsStream
16
19
  from aioads.transport import ITransport
@@ -114,9 +117,12 @@ class SymbolInfo:
114
117
  type_name_length,
115
118
  comment_length,
116
119
  ) = data.read_struct(SymbolInfo.FIXED_STRUCT)
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")
119
- comment = data.read(comment_length + 1).rstrip(b"\x00").decode("cp1252")
120
+ symbol_name = data.read(symbol_name_length +
121
+ 1).rstrip(b"\x00").decode("cp1252")
122
+ type_name = data.read(
123
+ type_name_length + 1).rstrip(b"\x00").decode("cp1252")
124
+ comment = data.read(
125
+ comment_length + 1).rstrip(b"\x00").decode("cp1252")
120
126
 
121
127
  # Move the stream position to the end of the entry
122
128
  data.seek(start_pos + entry_length)
@@ -180,7 +186,8 @@ class SymbolInfoByNameEx(IAdsFunction[SymbolInfo]):
180
186
  )
181
187
  header, read_payload = await command.request()
182
188
  if not header.error_code.ok:
183
- raise AdsCommandError(header.error_code, "Failed to read symbol info")
189
+ raise AdsCommandError(
190
+ header.error_code, "Failed to read symbol info")
184
191
  symbol_info = SymbolInfo.deserialize(read_payload)
185
192
  return symbol_info
186
193
 
@@ -189,21 +196,21 @@ class SymbolInfoByNameExSumRead(IAdsFunction[list[tuple[AdsErrorCode, SymbolInfo
189
196
  """
190
197
  This function utilizes the `AdsSumReadWrite` function to
191
198
  send multiple `SymbolInfoByNameEx` function calls in a batch.
192
- Requests for more symbols than the PLC-side batch limit are
193
- transparently split into multiple sum read/write calls.
199
+ Requests for more symbols than `batch_size` are transparently
200
+ split into multiple sum read/write calls by `AdsSumReadWrite`.
194
201
  """
195
202
 
196
- BATCH_SIZE = 500
197
-
198
203
  def __init__(
199
204
  self,
200
205
  transport: ITransport,
201
206
  ams_address: AmsAddress,
202
207
  symbol_names: list[str],
208
+ batch_size: int,
203
209
  ) -> None:
204
210
  self.transport = transport
205
211
  self.ams_address = ams_address
206
212
  self.symbol_names = symbol_names
213
+ self.batch_size = batch_size
207
214
 
208
215
  def create_sub_commands(self) -> list[AdsReadWriteCommand]:
209
216
  """
@@ -226,20 +233,19 @@ class SymbolInfoByNameExSumRead(IAdsFunction[list[tuple[AdsErrorCode, SymbolInfo
226
233
  return commands
227
234
 
228
235
  async def execute(self):
229
- commands = self.create_sub_commands()
236
+ command = AdsSumReadWrite(
237
+ transport=self.transport,
238
+ ams_address=self.ams_address,
239
+ commands=self.create_sub_commands(),
240
+ batch_size=self.batch_size,
241
+ )
230
242
  response = list[tuple[AdsErrorCode, SymbolInfo]]()
231
- for batch_start in range(0, len(commands), self.BATCH_SIZE):
232
- command = AdsSumReadWrite(
233
- transport=self.transport,
234
- ams_address=self.ams_address,
235
- commands=commands[batch_start : batch_start + self.BATCH_SIZE],
236
- )
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
243
- symbol_info = SymbolInfo.deserialize(read_payload)
244
- response.append((header.error_code, symbol_info))
243
+ for header, read_payload in await command.execute():
244
+ # If we have an error and no payload we return a void symbol info
245
+ # we raise no exception to let the client handle the error for each symbol individually
246
+ if not header.error_code.ok and read_payload.length == 0:
247
+ response.append((header.error_code, SymbolInfo.void()))
248
+ continue
249
+ symbol_info = SymbolInfo.deserialize(read_payload)
250
+ response.append((header.error_code, symbol_info))
245
251
  return response
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aioads"
3
- version = "0.1.0.dev9"
3
+ version = "0.1.0.dev11"
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" },
@@ -22,6 +22,7 @@ Like the performance tests, the budgets below are generous upper bounds, not
22
22
  benchmarks. A failure means a leak or a pathological regression, not a small
23
23
  change. Adjust the constants when they no longer match the test environment.
24
24
  """
25
+ import asyncio
25
26
  import gc
26
27
  import tracemalloc
27
28
 
@@ -60,8 +61,29 @@ MEASURED_CYCLES = 5
60
61
  CYCLE_GROWTH_MAX_BYTES = 64 * 1024
61
62
 
62
63
 
63
- def _heap_bytes() -> int:
64
- """Collect garbage and return the currently traced heap size."""
64
+ async def _drain_cancelled_timers() -> None:
65
+ """
66
+ Force asyncio to evict cancelled timer handles from its schedule.
67
+
68
+ Every transport request arms a `wait_for` timeout; the response arrives
69
+ long before it fires, so the timer is cancelled — but cancelled
70
+ `TimerHandle`s stay reachable in the loop's schedule until asyncio's lazy
71
+ cleanup runs (only once cancelled handles exceed 100 and half the heap).
72
+ Heap snapshots would otherwise measure a random phase of that saw-tooth
73
+ (tens of KiB) instead of actual growth. Scheduling and cancelling enough
74
+ dummy timers pushes the cancelled ratio over the cleanup threshold, so
75
+ the next loop iteration compacts the schedule deterministically.
76
+ """
77
+ loop = asyncio.get_running_loop()
78
+ handles = [loop.call_later(3600, lambda: None) for _ in range(256)]
79
+ for handle in handles:
80
+ handle.cancel()
81
+ await asyncio.sleep(0)
82
+
83
+
84
+ async def _heap_bytes() -> int:
85
+ """Drain cancelled timers, collect garbage and return the traced heap size."""
86
+ await _drain_cancelled_timers()
65
87
  gc.collect()
66
88
  return tracemalloc.get_traced_memory()[0]
67
89
 
@@ -102,28 +124,28 @@ class TestMemory(unittest.IsolatedAsyncioTestCase):
102
124
 
103
125
  async def test_read_symbols_memory_lifecycle(self) -> None:
104
126
  """Measure startup cost, runtime growth and cleanup residue."""
105
- baseline = _heap_bytes()
127
+ baseline = await _heap_bytes()
106
128
 
107
129
  # Part 1: startup - connect (datatype upload) + first cache fill.
108
130
  client = create_client(self.config)
109
131
  await client.connect()
110
132
  await self._read_once(client)
111
- startup_cost = _heap_bytes() - baseline
133
+ startup_cost = await _heap_bytes() - baseline
112
134
 
113
135
  # Part 2: runtime - steady-state growth and transient peak.
114
136
  for _ in range(WARMUP_READS):
115
137
  await self._read_once(client)
116
- before_reads = _heap_bytes()
138
+ before_reads = await _heap_bytes()
117
139
  tracemalloc.reset_peak()
118
140
  for _ in range(MEASURED_READS):
119
141
  await self._read_once(client)
120
142
  runtime_peak = tracemalloc.get_traced_memory()[1] - before_reads
121
- runtime_growth = _heap_bytes() - before_reads
143
+ runtime_growth = await _heap_bytes() - before_reads
122
144
 
123
145
  # Part 3: cleanup - disconnect, drop the client, measure the residue.
124
146
  await client.disconnect()
125
147
  del client
126
- residue = _heap_bytes() - baseline
148
+ residue = await _heap_bytes() - baseline
127
149
 
128
150
  print(
129
151
  f"\nMemory lifecycle report"
@@ -173,11 +195,11 @@ class TestMemory(unittest.IsolatedAsyncioTestCase):
173
195
  # The first cycle absorbs one-time allocations that live for the
174
196
  # rest of the interpreter (loggers, enum caches, interned strings).
175
197
  await one_cycle()
176
- baseline = _heap_bytes()
198
+ baseline = await _heap_bytes()
177
199
 
178
200
  for _ in range(MEASURED_CYCLES):
179
201
  await one_cycle()
180
- growth = _heap_bytes() - baseline
202
+ growth = await _heap_bytes() - baseline
181
203
 
182
204
  print(
183
205
  f"\nConnect cycle report: {MEASURED_CYCLES} cycles grew the heap "
@@ -3,6 +3,8 @@
3
3
  import unittest
4
4
  from unittest.mock import AsyncMock
5
5
 
6
+ from parameterized import parameterized
7
+
6
8
  from aioads.commands.ads_read import AdsReadCommand
7
9
  from aioads.functions.ads_sum_read import AdsSumRead
8
10
  from tests.builders import (
@@ -24,9 +26,12 @@ class TestAdsSumRead(unittest.IsolatedAsyncioTestCase):
24
26
  AdsReadCommand(self.transport, self.address, 0x4020, 8, 2),
25
27
  ]
26
28
 
27
- def _function(self, commands) -> AdsSumRead:
29
+ def _function(self, commands, batch_size: int = 500) -> AdsSumRead:
28
30
  return AdsSumRead(
29
- transport=self.transport, ams_address=self.address, commands=commands
31
+ transport=self.transport,
32
+ ams_address=self.address,
33
+ commands=commands,
34
+ batch_size=batch_size,
30
35
  )
31
36
 
32
37
  def test_serialize_concatenates_each_command_payload(self) -> None:
@@ -34,33 +39,65 @@ class TestAdsSumRead(unittest.IsolatedAsyncioTestCase):
34
39
  function = self._function(self.commands)
35
40
 
36
41
  # Act
37
- result = function.serialize()
42
+ result = function.serialize(self.commands)
38
43
 
39
44
  # Assert
40
45
  self.assertEqual(
41
46
  result, self.commands[0].serialize() + self.commands[1].serialize()
42
47
  )
43
48
 
44
- async def test_execute_empty_commands_raises_value_error(self) -> None:
49
+ @parameterized.expand([
50
+ ("zero", 0),
51
+ ("above_maximum", 501),
52
+ ])
53
+ async def test_execute_batch_size_out_of_range_raises_value_error(
54
+ self, _name: str, batch_size: int
55
+ ) -> None:
45
56
  # Arrange
46
- function = self._function([])
57
+ function = self._function(self.commands, batch_size=batch_size)
47
58
 
48
59
  # Act / Assert
49
60
  with self.assertRaises(ValueError) as ctx:
50
61
  await function.execute()
51
62
 
52
- self.assertIn("At least one command", str(ctx.exception))
63
+ self.assertIn("between 1 and 500", str(ctx.exception))
53
64
 
54
- async def test_execute_too_many_commands_raises_value_error(self) -> None:
65
+ async def test_execute_empty_commands_raises_value_error(self) -> None:
55
66
  # Arrange
56
- many = [AdsReadCommand(self.transport, self.address, 0, 0, 1) for _ in range(501)]
57
- function = self._function(many)
67
+ function = self._function([])
58
68
 
59
69
  # Act / Assert
60
70
  with self.assertRaises(ValueError) as ctx:
61
71
  await function.execute()
62
72
 
63
- self.assertIn("maximum is 500", str(ctx.exception))
73
+ self.assertIn("At least one command", str(ctx.exception))
74
+
75
+ async def test_execute_splits_commands_into_batches(self) -> None:
76
+ # Arrange: 3 commands with batch_size 2 -> one request with 2 commands,
77
+ # one request with the remaining command
78
+ commands = self.commands + [
79
+ AdsReadCommand(self.transport, self.address, 0x4020, 16, 1)
80
+ ]
81
+ first_batch_body = (
82
+ (0).to_bytes(4, "little") + (0).to_bytes(4, "little")
83
+ + b"\x01\x02\x03\x04" + b"\xaa\xbb"
84
+ )
85
+ second_batch_body = (0).to_bytes(4, "little") + b"\xcc"
86
+ self.transport.request = AsyncMock(
87
+ side_effect=[
88
+ (make_ams_header(), make_stream(make_read_payload(first_batch_body))),
89
+ (make_ams_header(), make_stream(make_read_payload(second_batch_body))),
90
+ ]
91
+ )
92
+ function = self._function(commands, batch_size=2)
93
+
94
+ # Act
95
+ response = await function.execute()
96
+
97
+ # Assert
98
+ self.assertEqual(self.transport.request.await_count, 2)
99
+ self.assertEqual(len(response), 3)
100
+ self.assertEqual(response[2][1].read(1), b"\xcc")
64
101
 
65
102
  async def test_execute_returns_response_and_data_per_command(self) -> None:
66
103
  # Arrange: 2 error codes (4 bytes each) followed by 4 + 2 data bytes
@@ -3,6 +3,8 @@
3
3
  import unittest
4
4
  from unittest.mock import AsyncMock
5
5
 
6
+ from parameterized import parameterized
7
+
6
8
  from aioads.commands.ads_read import AdsReadResponse
7
9
  from aioads.commands.ads_read_write import AdsReadWriteCommand
8
10
  from aioads.ads_error_codes import AdsErrorCode
@@ -26,9 +28,12 @@ class TestAdsSumReadWrite(unittest.IsolatedAsyncioTestCase):
26
28
  AdsReadWriteCommand(self.transport, self.address, 0xF009, 0, 2, 3, b"xyz"),
27
29
  ]
28
30
 
29
- def _function(self, commands) -> AdsSumReadWrite:
31
+ def _function(self, commands, batch_size: int = 500) -> AdsSumReadWrite:
30
32
  return AdsSumReadWrite(
31
- transport=self.transport, ams_address=self.address, commands=commands
33
+ transport=self.transport,
34
+ ams_address=self.address,
35
+ commands=commands,
36
+ batch_size=batch_size,
32
37
  )
33
38
 
34
39
  def test_serialize_places_all_headers_before_all_payloads(self) -> None:
@@ -36,7 +41,7 @@ class TestAdsSumReadWrite(unittest.IsolatedAsyncioTestCase):
36
41
  function = self._function(self.commands)
37
42
 
38
43
  # Act
39
- result = function.serialize()
44
+ result = function.serialize(self.commands)
40
45
 
41
46
  # Assert
42
47
  expected = (
@@ -57,19 +62,52 @@ class TestAdsSumReadWrite(unittest.IsolatedAsyncioTestCase):
57
62
 
58
63
  self.assertIn("At least one command", str(ctx.exception))
59
64
 
60
- async def test_execute_too_many_commands_raises_value_error(self) -> None:
65
+ @parameterized.expand([
66
+ ("zero", 0),
67
+ ("above_maximum", 501),
68
+ ])
69
+ async def test_execute_batch_size_out_of_range_raises_value_error(
70
+ self, _name: str, batch_size: int
71
+ ) -> None:
61
72
  # Arrange
62
- many = [
63
- AdsReadWriteCommand(self.transport, self.address, 0, 0, 1, 0, b"")
64
- for _ in range(501)
65
- ]
66
- function = self._function(many)
73
+ function = self._function(self.commands, batch_size=batch_size)
67
74
 
68
75
  # Act / Assert
69
76
  with self.assertRaises(ValueError) as ctx:
70
77
  await function.execute()
71
78
 
72
- self.assertIn("maximum is 500", str(ctx.exception))
79
+ self.assertIn("between 1 and 500", str(ctx.exception))
80
+
81
+ async def test_execute_splits_commands_into_batches(self) -> None:
82
+ # Arrange: 3 commands with batch_size 2 -> one request with 2 commands,
83
+ # one request with the remaining command
84
+ commands = self.commands + [
85
+ AdsReadWriteCommand(self.transport, self.address, 0xF009, 0, 1, 1, b"z")
86
+ ]
87
+ first_batch_body = (
88
+ AdsReadResponse(AdsErrorCode(0), length=4).serialize()
89
+ + AdsReadResponse(AdsErrorCode(0), length=2).serialize()
90
+ + b"\x01\x02\x03\x04"
91
+ + b"\xaa\xbb"
92
+ )
93
+ second_batch_body = (
94
+ AdsReadResponse(AdsErrorCode(0), length=1).serialize() + b"\xcc"
95
+ )
96
+ self.transport.request = AsyncMock(
97
+ side_effect=[
98
+ (make_ams_header(), make_stream(make_read_payload(first_batch_body))),
99
+ (make_ams_header(), make_stream(make_read_payload(second_batch_body))),
100
+ ]
101
+ )
102
+ function = self._function(commands, batch_size=2)
103
+
104
+ # Act
105
+ response = await function.execute()
106
+
107
+ # Assert
108
+ self.assertEqual(self.transport.request.await_count, 2)
109
+ self.assertEqual(len(response), 3)
110
+ self.assertEqual(response[2][1].read(1), b"\xcc")
73
111
 
74
112
  async def test_execute_returns_read_response_and_data_per_command(self) -> None:
75
113
  # Arrange: per-command (error, length) headers, then per-command payloads
@@ -107,6 +107,7 @@ class TestSymbolInfoByNameExSumRead(unittest.IsolatedAsyncioTestCase):
107
107
  transport=self.transport,
108
108
  ams_address=make_ams_address(),
109
109
  symbol_names=["main.a", "main.b"],
110
+ batch_size=500,
110
111
  )
111
112
 
112
113
  def test_create_sub_commands_one_per_symbol_name(self) -> None:
@@ -72,7 +72,7 @@ class TestAdsClient(unittest.IsolatedAsyncioTestCase):
72
72
  self.transport = make_transport()
73
73
  self.address = make_ams_address()
74
74
  self.parser = AdsSymbolParser([])
75
- self.cache = AdsSymbolCache(self.transport, self.address)
75
+ self.cache = AdsSymbolCache(self.transport, self.address, batch_size=500)
76
76
  self.notification = NotificationManager(
77
77
  self.transport, self.address, self.cache, self.parser
78
78
  )
@@ -82,6 +82,7 @@ class TestAdsClient(unittest.IsolatedAsyncioTestCase):
82
82
  parser=self.parser,
83
83
  cache=self.cache,
84
84
  notification=self.notification,
85
+ sum_batch_size=500,
85
86
  )
86
87
 
87
88
  async def test_read_symbol_info_by_name_delegates_to_cache(self) -> None:
@@ -64,7 +64,7 @@ class TestNotificationManager(unittest.IsolatedAsyncioTestCase):
64
64
  self.transport = make_transport()
65
65
  self.address = make_ams_address()
66
66
  # Real typed collaborators; only the methods under control are replaced.
67
- self.cache = AdsSymbolCache(self.transport, self.address)
67
+ self.cache = AdsSymbolCache(self.transport, self.address, batch_size=500)
68
68
  self.parser = AdsSymbolParser([])
69
69
  self.manager = NotificationManager(
70
70
  transport=self.transport,
@@ -60,6 +60,7 @@ class TestAdsSymbolCache(unittest.IsolatedAsyncioTestCase):
60
60
  transport=self.transport,
61
61
  dst_address=self.address,
62
62
  ttl_seconds=3600,
63
+ batch_size=500,
63
64
  )
64
65
  self.info = make_symbol_info("MAIN.VALUE")
65
66
 
@@ -98,7 +99,9 @@ class TestAdsSymbolCache(unittest.IsolatedAsyncioTestCase):
98
99
  async def test_read_symbol_info_by_name_expired_entry_refetches(self) -> None:
99
100
  # Arrange — zero TTL means an entry is never valid on the next lookup.
100
101
  # A fresh stream is built per call so the second fetch reads from the start.
101
- cache = AdsSymbolCache(self.transport, self.address, ttl_seconds=0)
102
+ cache = AdsSymbolCache(
103
+ self.transport, self.address, ttl_seconds=0, batch_size=500
104
+ )
102
105
  self.transport.request = AsyncMock(
103
106
  side_effect=lambda **kwargs: single_read_response(self.info)
104
107
  )
@@ -149,7 +152,9 @@ class TestAdsSymbolCacheMonitor(unittest.IsolatedAsyncioTestCase):
149
152
 
150
153
  def setUp(self) -> None:
151
154
  self.transport = make_transport()
152
- self.cache = AdsSymbolCache(self.transport, make_ams_address())
155
+ self.cache = AdsSymbolCache(
156
+ self.transport, make_ams_address(), batch_size=500
157
+ )
153
158
  # Symbol-table-version read keeps the monitor loop body from erroring.
154
159
  self.transport.request = AsyncMock(
155
160
  return_value=(make_ams_header(), make_stream(make_read_payload(b"\x01")))
File without changes
File without changes
File without changes
File without changes