aioads 0.1.0.dev9__tar.gz → 0.1.0.dev10__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.dev9 → aioads-0.1.0.dev10}/PKG-INFO +1 -1
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ads_client.py +25 -5
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ads_symbol_cache.py +3 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_sum_read.py +33 -14
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_sum_read_write.py +32 -16
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_info_by_name_ex.py +30 -24
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/pyproject.toml +1 -1
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/test_memory.py +31 -9
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_sum_read.py +47 -10
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_sum_read_write.py +48 -10
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +1 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ads_client.py +2 -1
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ads_notifications.py +1 -1
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ads_symbol_cache.py +7 -2
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/.github/dependabot.yml +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/.github/workflows/ci.yml +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/.github/workflows/publish.yml +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/.gitignore +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/.vscode/settings.json +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/LICENSE +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/README.md +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ads_error_codes.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ads_notifications.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ads_symbol_parser.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ams_address.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ams_header.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ams_service_port.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/ams_tcp_header.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_add_notification.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_command.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_delete_notification.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_read.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_read_device_info.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_read_state.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_read_write.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_write.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/ads_write_state.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/commands/errors.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/errors.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_enable_route.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_function.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_table_version.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_upload.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_upload_info.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/py.typed +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/stream.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/transport.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/aioads/utils/local_ip.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/docs/transmission_mode.md +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/docs/unittest_style_guide.html +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/examples/read_cmd_reuse_mqtt.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/examples/read_cycles.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/examples/read_cycles_mqtt.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/examples/read_multiple.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/examples/read_multiple_mqtt.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/examples/read_single.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/pdm.lock +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/__init__.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/builders.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/README.md +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/__init__.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/base.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/config.example.toml +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/config.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/test_connection.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/test_performance.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/integration/test_read_symbols.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/__init__.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/__init__.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_add_notification.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_command.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_delete_notification.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_read.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_read_device_info.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_read_state.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_read_write.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_write.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_write_state.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_errors.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/__init__.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_enable_route.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_function.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_datatype_by_name.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_datatype_upload.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_table_version.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_upload.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_upload_info.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ads_error_codes.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ads_symbol_parser.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ams_address.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ams_header.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_ams_tcp_header.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_errors.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_stream.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/test_transport.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/utils/__init__.py +0 -0
- {aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/utils/test_local_ip.py +0 -0
|
@@ -50,17 +50,24 @@ class AdsClient:
|
|
|
50
50
|
parser: ISymbolParser,
|
|
51
51
|
cache: AdsSymbolCache,
|
|
52
52
|
notification: NotificationManager,
|
|
53
|
+
sum_batch_size: int,
|
|
53
54
|
) -> None:
|
|
54
55
|
self.logger = logging.getLogger(f"{__name__}.'{dst_address.net_id}'")
|
|
55
56
|
self.transport = transport
|
|
56
57
|
self.dst_address = dst_address
|
|
57
58
|
self.parser = parser
|
|
59
|
+
self.sum_batch_size = sum_batch_size
|
|
58
60
|
self._cache = cache
|
|
59
61
|
self._notification = notification
|
|
60
62
|
|
|
61
63
|
@classmethod
|
|
62
64
|
def create_tcp(
|
|
63
|
-
cls,
|
|
65
|
+
cls,
|
|
66
|
+
src: AmsAddress,
|
|
67
|
+
dst: AmsAddress,
|
|
68
|
+
ip: str,
|
|
69
|
+
port: int = 48898,
|
|
70
|
+
sum_batch_size: int = 500,
|
|
64
71
|
) -> "AdsClient":
|
|
65
72
|
"""
|
|
66
73
|
Create a new ADS client with TCP transport.
|
|
@@ -68,11 +75,14 @@ class AdsClient:
|
|
|
68
75
|
:param dst: The destination AMS address
|
|
69
76
|
:param ip: The target IP address
|
|
70
77
|
:param port: The target port
|
|
78
|
+
:param sum_batch_size: Maximum commands per ADS sum command (1..500)
|
|
71
79
|
:return: An instance of AdsClient
|
|
72
80
|
"""
|
|
73
81
|
parser = AdsSymbolParser([])
|
|
74
82
|
transport = AdsTcpTransport(src_address=src, ip=ip, port=port)
|
|
75
|
-
cache = AdsSymbolCache(
|
|
83
|
+
cache = AdsSymbolCache(
|
|
84
|
+
transport=transport, dst_address=dst, batch_size=sum_batch_size
|
|
85
|
+
)
|
|
76
86
|
notification_manager = NotificationManager(
|
|
77
87
|
transport=transport,
|
|
78
88
|
dst_address=dst,
|
|
@@ -85,21 +95,28 @@ class AdsClient:
|
|
|
85
95
|
dst_address=dst,
|
|
86
96
|
cache=cache,
|
|
87
97
|
notification=notification_manager,
|
|
98
|
+
sum_batch_size=sum_batch_size,
|
|
88
99
|
)
|
|
89
100
|
|
|
90
101
|
@classmethod
|
|
91
102
|
def create_from_transport(
|
|
92
|
-
cls,
|
|
103
|
+
cls,
|
|
104
|
+
dst: AmsAddress,
|
|
105
|
+
transport: ITransport,
|
|
106
|
+
sum_batch_size: int = 500,
|
|
93
107
|
) -> "AdsClient":
|
|
94
108
|
"""
|
|
95
109
|
Create a new ADS client with an existing transport instance.
|
|
96
110
|
:param src: The source AMS address
|
|
97
111
|
:param dst: The destination AMS address
|
|
98
112
|
:param transport: The existing transport instance
|
|
113
|
+
:param sum_batch_size: Maximum commands per ADS sum command (1..500)
|
|
99
114
|
:return: An instance of AdsClient
|
|
100
115
|
"""
|
|
101
116
|
parser = AdsSymbolParser([])
|
|
102
|
-
cache = AdsSymbolCache(
|
|
117
|
+
cache = AdsSymbolCache(
|
|
118
|
+
transport=transport, dst_address=dst, batch_size=sum_batch_size
|
|
119
|
+
)
|
|
103
120
|
notification_manager = NotificationManager(
|
|
104
121
|
transport=transport,
|
|
105
122
|
dst_address=dst,
|
|
@@ -112,6 +129,7 @@ class AdsClient:
|
|
|
112
129
|
dst_address=dst,
|
|
113
130
|
cache=cache,
|
|
114
131
|
notification=notification_manager,
|
|
132
|
+
sum_batch_size=sum_batch_size,
|
|
115
133
|
)
|
|
116
134
|
|
|
117
135
|
async def connect(self) -> None:
|
|
@@ -255,7 +273,8 @@ class AdsClient:
|
|
|
255
273
|
exceptions.append(AdsCommandError(error_code, symbol_path))
|
|
256
274
|
|
|
257
275
|
if exceptions:
|
|
258
|
-
raise ExceptionGroup(
|
|
276
|
+
raise ExceptionGroup(
|
|
277
|
+
"One or more symbol read errors occurred", exceptions)
|
|
259
278
|
|
|
260
279
|
async def read_symbols_by_names(
|
|
261
280
|
self, symbol_names: set[str], raise_errors: bool = True
|
|
@@ -299,6 +318,7 @@ class AdsClient:
|
|
|
299
318
|
)
|
|
300
319
|
for _, info in readable
|
|
301
320
|
],
|
|
321
|
+
batch_size=self.sum_batch_size,
|
|
302
322
|
)
|
|
303
323
|
response = await sum_read.execute()
|
|
304
324
|
|
|
@@ -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
|
|
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
|
|
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
|
|
46
|
+
serialize the given commands to bytes
|
|
40
47
|
"""
|
|
41
|
-
return b"".join(command.serialize() for command in
|
|
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(
|
|
46
|
-
|
|
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
|
-
|
|
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(
|
|
72
|
+
idx_offset=len(commands),
|
|
55
73
|
read_length=sum(
|
|
56
|
-
cmd.length + 4 for cmd in
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
85
|
-
data += b"".join(cmd.payload for cmd in
|
|
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
|
-
|
|
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(
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
140
|
+
len(commands) * AdsReadResponse.STRUCT_DEF.size
|
|
125
141
|
)
|
|
126
142
|
|
|
127
143
|
response: list[tuple[AdsReadResponse, AdsStream]] = []
|
|
128
|
-
for _ in
|
|
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
|
|
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 +
|
|
118
|
-
|
|
119
|
-
|
|
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(
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
@@ -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
|
|
64
|
-
"""
|
|
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,
|
|
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
|
-
|
|
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("
|
|
63
|
+
self.assertIn("between 1 and 500", str(ctx.exception))
|
|
53
64
|
|
|
54
|
-
async def
|
|
65
|
+
async def test_execute_empty_commands_raises_value_error(self) -> None:
|
|
55
66
|
# Arrange
|
|
56
|
-
|
|
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("
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
{aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py
RENAMED
|
@@ -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(
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_delete_notification.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_datatype_by_name.py
RENAMED
|
File without changes
|
{aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_datatype_upload.py
RENAMED
|
File without changes
|
{aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_table_version.py
RENAMED
|
File without changes
|
|
File without changes
|
{aioads-0.1.0.dev9 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_upload_info.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|