aioads 0.1.0.dev8__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.dev8 → aioads-0.1.0.dev10}/PKG-INFO +1 -1
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/ads_client.py +25 -5
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/ads_symbol_cache.py +3 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/ads_symbol_parser.py +39 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/functions/ads_sum_read.py +33 -14
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/functions/ads_sum_read_write.py +32 -16
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_info_by_name_ex.py +30 -24
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/pyproject.toml +2 -1
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/integration/test_memory.py +31 -9
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_sum_read.py +47 -10
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_sum_read_write.py +48 -10
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +1 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/test_ads_client.py +2 -1
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/test_ads_notifications.py +1 -1
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/test_ads_symbol_cache.py +7 -2
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/test_ads_symbol_parser.py +155 -11
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/.github/dependabot.yml +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/.github/workflows/ci.yml +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/.github/workflows/publish.yml +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/.gitignore +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/.vscode/settings.json +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/LICENSE +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/README.md +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/ads_error_codes.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/ads_notifications.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/ams_address.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/ams_header.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/ams_service_port.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/ams_tcp_header.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/commands/ads_add_notification.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/commands/ads_command.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/commands/ads_delete_notification.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/commands/ads_read.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/commands/ads_read_device_info.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/commands/ads_read_state.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/commands/ads_read_write.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/commands/ads_write.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/commands/ads_write_state.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/commands/errors.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/errors.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/functions/ads_enable_route.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/functions/ads_function.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_table_version.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_upload.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/functions/ads_symbol_upload_info.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/py.typed +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/stream.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/transport.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/aioads/utils/local_ip.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/docs/transmission_mode.md +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/docs/unittest_style_guide.html +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/examples/read_cmd_reuse_mqtt.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/examples/read_cycles.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/examples/read_cycles_mqtt.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/examples/read_multiple.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/examples/read_multiple_mqtt.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/examples/read_single.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/pdm.lock +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/__init__.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/builders.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/integration/README.md +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/integration/__init__.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/integration/base.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/integration/config.example.toml +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/integration/config.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/integration/test_connection.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/integration/test_performance.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/integration/test_read_symbols.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/__init__.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/commands/__init__.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_add_notification.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_command.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_delete_notification.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_read.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_read_device_info.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_read_state.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_read_write.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_write.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/commands/test_ads_write_state.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/commands/test_errors.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/functions/__init__.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_enable_route.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_function.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_datatype_by_name.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_datatype_upload.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_table_version.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_upload.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/functions/test_ads_symbol_upload_info.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/test_ads_error_codes.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/test_ams_address.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/test_ams_header.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/test_ams_tcp_header.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/test_errors.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/test_stream.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/test_transport.py +0 -0
- {aioads-0.1.0.dev8 → aioads-0.1.0.dev10}/tests/unit/utils/__init__.py +0 -0
- {aioads-0.1.0.dev8 → 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(
|
|
@@ -134,6 +134,37 @@ class AdsSymbolParser(ISymbolParser):
|
|
|
134
134
|
|
|
135
135
|
def __init__(self, data_types: list[SymbolDataTypeResponse]):
|
|
136
136
|
self._type_lookup = {dt.name: dt for dt in data_types}
|
|
137
|
+
# Resolution of IEC 61131-3 elementary type names to their wire encoding.
|
|
138
|
+
self._elementary_type_names: dict[str, AdsSymbolDataType] = {
|
|
139
|
+
"BOOL": AdsSymbolDataType.BIT,
|
|
140
|
+
"BIT": AdsSymbolDataType.BIT,
|
|
141
|
+
"SINT": AdsSymbolDataType.INT8,
|
|
142
|
+
"USINT": AdsSymbolDataType.UINT8,
|
|
143
|
+
"BYTE": AdsSymbolDataType.UINT8,
|
|
144
|
+
"INT": AdsSymbolDataType.INT16,
|
|
145
|
+
"UINT": AdsSymbolDataType.UINT16,
|
|
146
|
+
"WORD": AdsSymbolDataType.UINT16,
|
|
147
|
+
"DINT": AdsSymbolDataType.INT32,
|
|
148
|
+
"UDINT": AdsSymbolDataType.UINT32,
|
|
149
|
+
"DWORD": AdsSymbolDataType.UINT32,
|
|
150
|
+
"LINT": AdsSymbolDataType.INT64,
|
|
151
|
+
"ULINT": AdsSymbolDataType.UINT64,
|
|
152
|
+
"LWORD": AdsSymbolDataType.UINT64,
|
|
153
|
+
"REAL": AdsSymbolDataType.REAL32,
|
|
154
|
+
"LREAL": AdsSymbolDataType.REAL64,
|
|
155
|
+
"TIME": AdsSymbolDataType.UINT32,
|
|
156
|
+
"TOD": AdsSymbolDataType.UINT32,
|
|
157
|
+
"TIME_OF_DAY": AdsSymbolDataType.UINT32,
|
|
158
|
+
"DATE": AdsSymbolDataType.UINT32,
|
|
159
|
+
"DT": AdsSymbolDataType.UINT32,
|
|
160
|
+
"DATE_AND_TIME": AdsSymbolDataType.UINT32,
|
|
161
|
+
"LTIME": AdsSymbolDataType.UINT64,
|
|
162
|
+
"LTOD": AdsSymbolDataType.UINT64,
|
|
163
|
+
"LTIME_OF_DAY": AdsSymbolDataType.UINT64,
|
|
164
|
+
"LDATE": AdsSymbolDataType.UINT64,
|
|
165
|
+
"LDT": AdsSymbolDataType.UINT64,
|
|
166
|
+
"LDATE_AND_TIME": AdsSymbolDataType.UINT64,
|
|
167
|
+
}
|
|
137
168
|
self._const_type_names: dict[str, Callable[[str, AdsStream], Any]] = {
|
|
138
169
|
"Tc2_System.T_MaxString": lambda type_name, raw_data: raw_data.read(255)
|
|
139
170
|
.decode("latin-1")
|
|
@@ -142,6 +173,7 @@ class AdsSymbolParser(ISymbolParser):
|
|
|
142
173
|
.decode("latin-1")
|
|
143
174
|
.rstrip("\x00"),
|
|
144
175
|
}
|
|
176
|
+
|
|
145
177
|
self._primitive_parser = PrimitiveTypeParser()
|
|
146
178
|
|
|
147
179
|
def update_datatypes(self, data_types: list[SymbolDataTypeResponse]) -> None:
|
|
@@ -265,6 +297,13 @@ class AdsSymbolParser(ISymbolParser):
|
|
|
265
297
|
if data_type != AdsSymbolDataType.BIGTYPE:
|
|
266
298
|
return self._primitive_parser.parse(data_type, type_name, raw_data)
|
|
267
299
|
|
|
300
|
+
# TwinCAT 2 reports BIGTYPE for some elementary types
|
|
301
|
+
# like INT, UINT, REAL, etc. So we need to resolve them by name.
|
|
302
|
+
if type_name in self._elementary_type_names:
|
|
303
|
+
return self._primitive_parser.parse(
|
|
304
|
+
self._elementary_type_names[type_name], type_name, raw_data
|
|
305
|
+
)
|
|
306
|
+
|
|
268
307
|
# Parsing of structured types
|
|
269
308
|
if data_type == AdsSymbolDataType.BIGTYPE:
|
|
270
309
|
return self.parse_struct(type_name, raw_data)
|
|
@@ -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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "aioads"
|
|
3
|
-
version = "0.1.0.
|
|
3
|
+
version = "0.1.0.dev10"
|
|
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" },
|
|
@@ -78,4 +78,5 @@ disable = [
|
|
|
78
78
|
"too-many-locals",
|
|
79
79
|
"too-few-public-methods",
|
|
80
80
|
"broad-exception-caught",
|
|
81
|
+
"too-many-return-statements",
|
|
81
82
|
]
|
|
@@ -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 "
|