aioads 0.1.0.dev1__py3-none-any.whl
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/ads_client.py +333 -0
- aioads/ads_error_codes.py +160 -0
- aioads/ads_notifications.py +203 -0
- aioads/ads_symbol_cache.py +186 -0
- aioads/ads_symbol_parser.py +271 -0
- aioads/ams_address.py +53 -0
- aioads/ams_header.py +94 -0
- aioads/ams_tcp_header.py +54 -0
- aioads/commands/ads_add_notification.py +137 -0
- aioads/commands/ads_command.py +56 -0
- aioads/commands/ads_delete_notification.py +57 -0
- aioads/commands/ads_read.py +125 -0
- aioads/commands/ads_read_device_info.py +115 -0
- aioads/commands/ads_read_state.py +128 -0
- aioads/commands/ads_read_write.py +92 -0
- aioads/commands/ads_write.py +122 -0
- aioads/commands/ads_write_state.py +74 -0
- aioads/commands/errors.py +30 -0
- aioads/errors.py +32 -0
- aioads/functions/ads_enable_route.py +99 -0
- aioads/functions/ads_function.py +49 -0
- aioads/functions/ads_sum_read.py +79 -0
- aioads/functions/ads_sum_read_write.py +131 -0
- aioads/functions/ads_symbol_datatype_by_name.py +224 -0
- aioads/functions/ads_symbol_datatype_upload.py +56 -0
- aioads/functions/ads_symbol_info_by_name_ex.py +219 -0
- aioads/functions/ads_symbol_table_version.py +49 -0
- aioads/functions/ads_symbol_upload.py +51 -0
- aioads/functions/ads_symbol_upload_info.py +89 -0
- aioads/stream.py +90 -0
- aioads/transport.py +561 -0
- aioads/utils/local_ip.py +26 -0
- aioads-0.1.0.dev1.dist-info/METADATA +302 -0
- aioads-0.1.0.dev1.dist-info/RECORD +35 -0
- aioads-0.1.0.dev1.dist-info/WHEEL +4 -0
aioads/ads_client.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""ADS Client for communicating with ADS devices asynchronously."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from aioads.ads_error_codes import AdsErrorCode
|
|
11
|
+
from aioads.ads_notifications import NotificationManager
|
|
12
|
+
from aioads.ads_notifications import TNotificationCallback
|
|
13
|
+
from aioads.ads_symbol_cache import AdsSymbolCache
|
|
14
|
+
from aioads.ads_symbol_parser import AdsSymbolParser
|
|
15
|
+
from aioads.ads_symbol_parser import ISymbolParser
|
|
16
|
+
from aioads.ams_address import AmsAddress
|
|
17
|
+
from aioads.commands.ads_add_notification import TransmissionMode
|
|
18
|
+
from aioads.commands.ads_read import AdsReadCommand
|
|
19
|
+
from aioads.commands.ads_read_state import ReadStateCommand
|
|
20
|
+
from aioads.commands.errors import AdsCommandError
|
|
21
|
+
from aioads.functions.ads_enable_route import AdsEnableRoute
|
|
22
|
+
from aioads.functions.ads_enable_route import RouteSwitch
|
|
23
|
+
from aioads.functions.ads_sum_read import AdsSumRead
|
|
24
|
+
from aioads.functions.ads_symbol_datatype_by_name import AdsSymbolDataTypeByName
|
|
25
|
+
from aioads.functions.ads_symbol_datatype_upload import AdsSymbolDataTypeUpload
|
|
26
|
+
from aioads.functions.ads_symbol_info_by_name_ex import SymbolInfo
|
|
27
|
+
from aioads.functions.ads_symbol_upload import AdsSymbolUpload
|
|
28
|
+
from aioads.functions.ads_symbol_upload_info import AdsSymbolUploadInfo2
|
|
29
|
+
from aioads.transport import AdsTcpTransport
|
|
30
|
+
from aioads.transport import ITransport
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class SymbolReadResult:
|
|
35
|
+
"""
|
|
36
|
+
Result for a multi-symbol read operation.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
error_code: AdsErrorCode
|
|
40
|
+
value: Any
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AdsClient:
|
|
44
|
+
"""
|
|
45
|
+
ADS Client for communicating with ADS devices asynchronously.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
transport: ITransport,
|
|
51
|
+
dst_address: AmsAddress,
|
|
52
|
+
parser: ISymbolParser,
|
|
53
|
+
cache: AdsSymbolCache,
|
|
54
|
+
notification: NotificationManager,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.logger = logging.getLogger(f"{__name__}.'{dst_address.net_id}'")
|
|
57
|
+
self.transport = transport
|
|
58
|
+
self.dst_address = dst_address
|
|
59
|
+
self.parser = parser
|
|
60
|
+
self._cache = cache
|
|
61
|
+
self._notification = notification
|
|
62
|
+
self.parser_pool = ThreadPoolExecutor(
|
|
63
|
+
max_workers=(os.cpu_count() or 1) * 2
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def create_tcp(
|
|
68
|
+
cls, src: AmsAddress, dst: AmsAddress, ip: str, port: int = 48898
|
|
69
|
+
) -> "AdsClient":
|
|
70
|
+
"""
|
|
71
|
+
Create a new ADS client with TCP transport.
|
|
72
|
+
:param src: The source AMS address
|
|
73
|
+
:param dst: The destination AMS address
|
|
74
|
+
:param ip: The target IP address
|
|
75
|
+
:param port: The target port
|
|
76
|
+
:return: An instance of AdsClient
|
|
77
|
+
"""
|
|
78
|
+
parser = AdsSymbolParser([])
|
|
79
|
+
transport = AdsTcpTransport(src_address=src, ip=ip, port=port)
|
|
80
|
+
cache = AdsSymbolCache(transport=transport, dst_address=dst)
|
|
81
|
+
notification_manager = NotificationManager(
|
|
82
|
+
transport=transport,
|
|
83
|
+
dst_address=dst,
|
|
84
|
+
symbol_cache=cache,
|
|
85
|
+
parser=parser,
|
|
86
|
+
)
|
|
87
|
+
return cls(
|
|
88
|
+
transport=transport,
|
|
89
|
+
parser=parser,
|
|
90
|
+
dst_address=dst,
|
|
91
|
+
cache=cache,
|
|
92
|
+
notification=notification_manager,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def create_from_transport(cls, dst: AmsAddress, transport: ITransport) -> "AdsClient":
|
|
97
|
+
"""
|
|
98
|
+
Create a new ADS client with an existing transport instance.
|
|
99
|
+
:param src: The source AMS address
|
|
100
|
+
:param dst: The destination AMS address
|
|
101
|
+
:param transport: The existing transport instance
|
|
102
|
+
:return: An instance of AdsClient
|
|
103
|
+
"""
|
|
104
|
+
parser = AdsSymbolParser([])
|
|
105
|
+
cache = AdsSymbolCache(transport=transport, dst_address=dst)
|
|
106
|
+
notification_manager = NotificationManager(
|
|
107
|
+
transport=transport,
|
|
108
|
+
dst_address=dst,
|
|
109
|
+
symbol_cache=cache,
|
|
110
|
+
parser=parser,
|
|
111
|
+
)
|
|
112
|
+
return cls(
|
|
113
|
+
transport=transport,
|
|
114
|
+
parser=parser,
|
|
115
|
+
dst_address=dst,
|
|
116
|
+
cache=cache,
|
|
117
|
+
notification=notification_manager,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def connect(self) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Connect to the ADS device.
|
|
123
|
+
"""
|
|
124
|
+
await self.transport.connect()
|
|
125
|
+
await self._cache.start_cache_monitor()
|
|
126
|
+
# ? Should we fail here if symbol upload fails?
|
|
127
|
+
# ? If it fails, we are still able to read basic value types but not complex
|
|
128
|
+
# ? structures.
|
|
129
|
+
try:
|
|
130
|
+
types_ = await self.get_symbol_datatypes()
|
|
131
|
+
self.parser.update_datatypes(types_)
|
|
132
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
async def disconnect(self) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Disconnect from the ADS device.
|
|
138
|
+
"""
|
|
139
|
+
await self._cache.stop_cache_monitor()
|
|
140
|
+
await self.transport.disconnect()
|
|
141
|
+
|
|
142
|
+
async def read_state(self):
|
|
143
|
+
"""
|
|
144
|
+
Read the state of the ADS device.
|
|
145
|
+
"""
|
|
146
|
+
request = ReadStateCommand(
|
|
147
|
+
transport=self.transport,
|
|
148
|
+
ams_address=self.dst_address,
|
|
149
|
+
)
|
|
150
|
+
return await request.request()
|
|
151
|
+
|
|
152
|
+
async def enable_route(self, route_name: str, enabled: bool):
|
|
153
|
+
"""
|
|
154
|
+
Enable or disable a ads route.
|
|
155
|
+
Example route name for `ads over mqtt`: `MQTT:192.168.178.12.1.1:ads` (MQTT:<NetID>:<Topic>)
|
|
156
|
+
Example with defined name: `MQTT:MyBroker`
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
request = AdsEnableRoute(
|
|
160
|
+
self.transport,
|
|
161
|
+
self.dst_address,
|
|
162
|
+
route_name,
|
|
163
|
+
RouteSwitch.ROUTE_ENABLE_TMP if enabled else RouteSwitch.ROUTE_DISABLE_TMP
|
|
164
|
+
)
|
|
165
|
+
response = await request.execute()
|
|
166
|
+
if not response.error_code.ok:
|
|
167
|
+
raise AdsCommandError(response.error_code)
|
|
168
|
+
|
|
169
|
+
async def get_symbols(self):
|
|
170
|
+
"""
|
|
171
|
+
Get all root symbols from the ADS device.
|
|
172
|
+
"""
|
|
173
|
+
tree_info_request = AdsSymbolUploadInfo2(
|
|
174
|
+
transport=self.transport,
|
|
175
|
+
ams_address=self.dst_address,
|
|
176
|
+
)
|
|
177
|
+
tree_info = await tree_info_request.execute()
|
|
178
|
+
|
|
179
|
+
request = AdsSymbolUpload(
|
|
180
|
+
transport=self.transport,
|
|
181
|
+
ams_address=self.dst_address,
|
|
182
|
+
tree_size=tree_info.symbol_size,
|
|
183
|
+
)
|
|
184
|
+
return await request.execute()
|
|
185
|
+
|
|
186
|
+
async def get_symbol_datatypes(self):
|
|
187
|
+
"""
|
|
188
|
+
Get all symbol datatypes from the ADS device.
|
|
189
|
+
"""
|
|
190
|
+
tree_info_request = AdsSymbolUploadInfo2(
|
|
191
|
+
transport=self.transport,
|
|
192
|
+
ams_address=self.dst_address,
|
|
193
|
+
)
|
|
194
|
+
tree_info = await tree_info_request.execute()
|
|
195
|
+
request = AdsSymbolDataTypeUpload(
|
|
196
|
+
transport=self.transport,
|
|
197
|
+
ams_address=self.dst_address,
|
|
198
|
+
dt_size=tree_info.datatype_size,
|
|
199
|
+
)
|
|
200
|
+
return await request.execute()
|
|
201
|
+
|
|
202
|
+
async def read_datatype_by_name(self, datatype_name: str):
|
|
203
|
+
"""
|
|
204
|
+
Read a datatype by its name from the ADS device.
|
|
205
|
+
"""
|
|
206
|
+
request = AdsSymbolDataTypeByName(
|
|
207
|
+
transport=self.transport,
|
|
208
|
+
ams_address=self.dst_address,
|
|
209
|
+
datatype_name=datatype_name,
|
|
210
|
+
)
|
|
211
|
+
return await request.execute()
|
|
212
|
+
|
|
213
|
+
async def read_symbol_info_by_name(self, symbol_name: str):
|
|
214
|
+
"""
|
|
215
|
+
Read symbol information by its name from the ADS device.
|
|
216
|
+
"""
|
|
217
|
+
return await self._cache.read_symbol_info_by_name(symbol_name)
|
|
218
|
+
|
|
219
|
+
async def read_symbol_infos_by_names(self, symbol_names: set[str]):
|
|
220
|
+
"""
|
|
221
|
+
Read symbol information for multiple symbols by their names from the ADS device.
|
|
222
|
+
"""
|
|
223
|
+
return await self._cache.read_symbol_infos_by_names(symbol_names)
|
|
224
|
+
|
|
225
|
+
async def read_symbol_by_name(self, symbol_name: str) -> Any:
|
|
226
|
+
"""
|
|
227
|
+
Read a symbol by its name from the ADS device.
|
|
228
|
+
"""
|
|
229
|
+
symbol_info = await self._cache.read_symbol_info_by_name(symbol_name)
|
|
230
|
+
read_command = AdsReadCommand(
|
|
231
|
+
transport=self.transport,
|
|
232
|
+
ams_address=self.dst_address,
|
|
233
|
+
idx_group=symbol_info.idx_group,
|
|
234
|
+
idx_offset=symbol_info.idx_offset,
|
|
235
|
+
length=symbol_info.idx_length,
|
|
236
|
+
)
|
|
237
|
+
_, payload = await read_command.request()
|
|
238
|
+
return self.parser.parse(
|
|
239
|
+
data_type=symbol_info.data_type,
|
|
240
|
+
type_name=symbol_info.type_name,
|
|
241
|
+
raw_data=payload,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _raise_if_error(
|
|
245
|
+
self, symbol_infos: dict[str, tuple[AdsErrorCode, SymbolInfo]]
|
|
246
|
+
) -> None:
|
|
247
|
+
"""
|
|
248
|
+
Generate a human-readable error message for symbol read errors.
|
|
249
|
+
"""
|
|
250
|
+
symbol_errors = [
|
|
251
|
+
(error_code, name)
|
|
252
|
+
for name, (error_code, _) in symbol_infos.items()
|
|
253
|
+
if error_code != 0
|
|
254
|
+
]
|
|
255
|
+
exceptions: list[AdsCommandError] = []
|
|
256
|
+
for error_code, symbol_name in symbol_errors:
|
|
257
|
+
symbol_path = symbol_name.replace(".", " → ")
|
|
258
|
+
exceptions.append(AdsCommandError(error_code, symbol_path))
|
|
259
|
+
|
|
260
|
+
if exceptions:
|
|
261
|
+
raise ExceptionGroup(
|
|
262
|
+
"One or more symbol read errors occurred", exceptions)
|
|
263
|
+
|
|
264
|
+
async def read_symbols_by_names(
|
|
265
|
+
self, symbol_names: set[str], raise_errors: bool = True
|
|
266
|
+
) -> dict[str, SymbolReadResult]:
|
|
267
|
+
"""
|
|
268
|
+
Read multiple symbols by their names from the ADS device.
|
|
269
|
+
"""
|
|
270
|
+
symbol_infos = await self._cache.read_symbol_infos_by_names(symbol_names)
|
|
271
|
+
if raise_errors:
|
|
272
|
+
self._raise_if_error(symbol_infos)
|
|
273
|
+
|
|
274
|
+
read_commands = [
|
|
275
|
+
AdsReadCommand(
|
|
276
|
+
transport=self.transport,
|
|
277
|
+
ams_address=self.dst_address,
|
|
278
|
+
idx_group=symbol_info.idx_group,
|
|
279
|
+
idx_offset=symbol_info.idx_offset,
|
|
280
|
+
length=symbol_info.idx_length,
|
|
281
|
+
)
|
|
282
|
+
for _, symbol_info in symbol_infos.values()
|
|
283
|
+
]
|
|
284
|
+
function = AdsSumRead(
|
|
285
|
+
transport=self.transport,
|
|
286
|
+
ams_address=self.dst_address,
|
|
287
|
+
commands=read_commands,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
response = await function.execute()
|
|
291
|
+
output: dict[str, SymbolReadResult] = {}
|
|
292
|
+
|
|
293
|
+
tasks = []
|
|
294
|
+
for (_, resp_payload), (error_code, symbol_info) in zip(
|
|
295
|
+
response, symbol_infos.values()
|
|
296
|
+
):
|
|
297
|
+
tasks.append(
|
|
298
|
+
asyncio.get_running_loop().run_in_executor(
|
|
299
|
+
self.parser_pool,
|
|
300
|
+
self.parser.parse,
|
|
301
|
+
symbol_info.data_type,
|
|
302
|
+
symbol_info.type_name,
|
|
303
|
+
resp_payload,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
parsed = await asyncio.gather(*tasks)
|
|
307
|
+
|
|
308
|
+
for symbol_data, (error_code, symbol_info) in zip(parsed, symbol_infos.values()):
|
|
309
|
+
output[symbol_info.symbol_name] = SymbolReadResult(
|
|
310
|
+
error_code, symbol_data)
|
|
311
|
+
|
|
312
|
+
return output
|
|
313
|
+
|
|
314
|
+
@asynccontextmanager
|
|
315
|
+
async def subscribe_notification(
|
|
316
|
+
self,
|
|
317
|
+
symbol_name: str,
|
|
318
|
+
callback: TNotificationCallback,
|
|
319
|
+
mode: TransmissionMode = TransmissionMode.CYCLIC,
|
|
320
|
+
cycle_time: int = 50,
|
|
321
|
+
max_delay: int = 100,
|
|
322
|
+
):
|
|
323
|
+
"""
|
|
324
|
+
Subscribe to notifications for a symbol by its name.
|
|
325
|
+
"""
|
|
326
|
+
async with self._notification.create_notification(
|
|
327
|
+
symbol_name,
|
|
328
|
+
callback,
|
|
329
|
+
mode,
|
|
330
|
+
cycle_time,
|
|
331
|
+
max_delay,
|
|
332
|
+
) as notification_handle:
|
|
333
|
+
yield notification_handle
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ADS Error Codes Module
|
|
3
|
+
https://infosys.beckhoff.com/english.php?content=../content/1033/tcadscommon/12440276875.html&id=
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AdsErrorCodes: # pylint: disable=too-few-public-methods
|
|
8
|
+
"""
|
|
9
|
+
ADS Error Codes for ADS protocol communication.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
ERROR_CODES = {
|
|
13
|
+
0: "no error",
|
|
14
|
+
1: "Internal error",
|
|
15
|
+
2: "No Rtime",
|
|
16
|
+
3: "Allocation locked memory error",
|
|
17
|
+
4: "Insert mailbox er",
|
|
18
|
+
5: "Wrong receive HMSG",
|
|
19
|
+
6: "target port not found ADS Server not started",
|
|
20
|
+
7: "target machine not found Missing ADS routes",
|
|
21
|
+
8: "Unknown command ID",
|
|
22
|
+
9: "Bad task ID",
|
|
23
|
+
10: "No IO",
|
|
24
|
+
11: "Unknown ADS command",
|
|
25
|
+
12: "Win 32 error",
|
|
26
|
+
13: "Port not connected",
|
|
27
|
+
14: "Invalid ADS length",
|
|
28
|
+
15: "Invalid ADS Net ID",
|
|
29
|
+
16: "Low Installation level",
|
|
30
|
+
17: "No debug available",
|
|
31
|
+
18: "Port disabled",
|
|
32
|
+
19: "Port already connected",
|
|
33
|
+
20: "ADS Sync Win32 error",
|
|
34
|
+
21: "ADS Sync Timeout",
|
|
35
|
+
22: "ADS Sync AMS error",
|
|
36
|
+
23: "ADS Sync no index map",
|
|
37
|
+
24: "Invalid ADS port",
|
|
38
|
+
25: "No memory",
|
|
39
|
+
26: "TCP send error",
|
|
40
|
+
27: "Host unreachable",
|
|
41
|
+
28: "Invalid AMS fragm",
|
|
42
|
+
1280: "ROUTERERR_NOLOCKEDMEMORY",
|
|
43
|
+
1281: "ROUTERERR_RESIZEMEMORY",
|
|
44
|
+
1282: "ROUTERERR_MAILBOXFULL",
|
|
45
|
+
1283: "ROUTERERR_DEBUGBOXFULL",
|
|
46
|
+
1284: "ROUTERERR_UNKNOWNPORTTYPE",
|
|
47
|
+
1285: "ROUTERERR_NOTINITIALIZED",
|
|
48
|
+
1286: "ROUTERERR_PORTALREADYINUSE",
|
|
49
|
+
1287: "ROUTERERR_NOTREGISTERED",
|
|
50
|
+
1288: "ROUTERERR_NOMOREQUEUES",
|
|
51
|
+
1289: "ROUTERERR_INVALIDPORT",
|
|
52
|
+
1290: "ROUTERERR_NOTACTIVATED",
|
|
53
|
+
1291: "ROUTERERR_FRAGMENTBOXFULL",
|
|
54
|
+
1292: "ROUTERERR_FRAGMENTTIMEOUT",
|
|
55
|
+
1293: "ROUTERERR_TOBEREMOVED",
|
|
56
|
+
1792: "error class <device error>",
|
|
57
|
+
1793: "Service is not supported by server",
|
|
58
|
+
1794: "invalid index group",
|
|
59
|
+
1795: "invalid index offset",
|
|
60
|
+
1796: "reading/writing not permitted",
|
|
61
|
+
1797: "parameter size not correct",
|
|
62
|
+
1798: "invalid parameter value(s)",
|
|
63
|
+
1799: "device is not in a ready state",
|
|
64
|
+
1800: "device is busy",
|
|
65
|
+
1801: "invalid context (must be in Windows)",
|
|
66
|
+
1802: "out of memory",
|
|
67
|
+
1803: "invalid parameter value(s)",
|
|
68
|
+
1804: "not found (files, ...)",
|
|
69
|
+
1805: "syntax error in command or file",
|
|
70
|
+
1806: "objects do not match",
|
|
71
|
+
1807: "object already exists",
|
|
72
|
+
1808: "symbol not found",
|
|
73
|
+
1809: "symbol version invalid. Onlinechange. Release handle and get a new one",
|
|
74
|
+
1810: "server is in invalid state",
|
|
75
|
+
1811: "AdsTransMode not supported",
|
|
76
|
+
1812: "Notification handle is invalid. Onlinechange. Release handle and get a new one", # noqa: E501
|
|
77
|
+
1813: "Notification client not registered",
|
|
78
|
+
1814: "no more notification handles",
|
|
79
|
+
1815: "size for watch too big",
|
|
80
|
+
1816: "device not initialized",
|
|
81
|
+
1817: "device has a timeout",
|
|
82
|
+
1818: "query interface failed",
|
|
83
|
+
1819: "wrong interface required",
|
|
84
|
+
1820: "class ID is invalid",
|
|
85
|
+
1821: "object ID is invalid",
|
|
86
|
+
1822: "requests is pending",
|
|
87
|
+
1823: "requests is aborted",
|
|
88
|
+
1824: "signal warning",
|
|
89
|
+
1825: "invalid array index",
|
|
90
|
+
1826: "symbol not active. Onlinechange. Release handle and get a new one",
|
|
91
|
+
1827: "access denied",
|
|
92
|
+
1828: "missing license. Activate license for TwinCAT 3 function",
|
|
93
|
+
1836: "exception occurred during system start. Check each device transistions",
|
|
94
|
+
1856: "Error class <client error>",
|
|
95
|
+
1857: "invalid parameter at service",
|
|
96
|
+
1858: "polling list is empty",
|
|
97
|
+
1859: "var connection already in use",
|
|
98
|
+
1860: "invoke ID in use",
|
|
99
|
+
1861: "timeout elapsed",
|
|
100
|
+
1862: "error in win32 subsystem",
|
|
101
|
+
1863: "Invalid client timeout value",
|
|
102
|
+
1864: "ads-port not opened",
|
|
103
|
+
1872: "internal error in ads sync",
|
|
104
|
+
1873: "hash table overflow",
|
|
105
|
+
1874: "key not found in hash",
|
|
106
|
+
1875: "no more symbols in cache",
|
|
107
|
+
1876: "invalid response received",
|
|
108
|
+
1877: "sync port is locked",
|
|
109
|
+
4096: "RTERR_INTERNAL",
|
|
110
|
+
4097: "RTERR_BADTIMERPERIODS",
|
|
111
|
+
4098: "RTERR_INVALIDTASKPTR",
|
|
112
|
+
4099: "RTERR_INVALIDSTACKPTR",
|
|
113
|
+
4100: "RTERR_PRIOEXISTS",
|
|
114
|
+
4101: "RTERR_NOMORETCB",
|
|
115
|
+
4102: "RTERR_NOMORESEMAS",
|
|
116
|
+
4103: "RTERR_NOMOREQUEUES",
|
|
117
|
+
4104: "TwinCAT reserved 1.",
|
|
118
|
+
4105: "TwinCAT reserved 2.",
|
|
119
|
+
4106: "TwinCAT reserved 3.",
|
|
120
|
+
4107: "TwinCAT reserved 4.",
|
|
121
|
+
4108: "TwinCAT reserved 5.",
|
|
122
|
+
4109: "RTERR_EXTIRQALREADYDEF",
|
|
123
|
+
4110: "RTERR_EXTIRQNOTDEF",
|
|
124
|
+
4111: "RTERR_EXTIRQINSTALLFAILED",
|
|
125
|
+
4112: "RTERR_IRQLNOTLESSOREQUAL",
|
|
126
|
+
4119: "RTERR_VMXNOTSUPPORTED",
|
|
127
|
+
4120: "RTERR_VMXDISABLED",
|
|
128
|
+
4121: "RTERR_VMXCONTROLSMISSING",
|
|
129
|
+
4122: "RTERR_VMXENABLEFAILS",
|
|
130
|
+
10060: "A socket operation was attempted to an unreachable host",
|
|
131
|
+
10061: "A connection attempt failed because the connected party did not properly respond after a "
|
|
132
|
+
"period of time, or established connection failed because connected host has failed to respond.",
|
|
133
|
+
# noqa: E501
|
|
134
|
+
10065: "No connection could be made because the target machine actively refused it",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def description(cls, error_code: int) -> str:
|
|
139
|
+
"""
|
|
140
|
+
Get the description for a given ADS error code.
|
|
141
|
+
"""
|
|
142
|
+
return cls.ERROR_CODES.get(error_code, "Unknown error code")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class AdsErrorCode(int):
|
|
146
|
+
"""ADS Error Code with description property"""
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def description(self) -> str:
|
|
150
|
+
"""
|
|
151
|
+
Get the description for this ADS error code.
|
|
152
|
+
"""
|
|
153
|
+
return AdsErrorCodes.description(self)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def ok(self) -> bool:
|
|
157
|
+
"""
|
|
158
|
+
Check if the error code represents a successful operation (error code 0).
|
|
159
|
+
"""
|
|
160
|
+
return self == 0
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module is still just a POC to verify the general functionality of ads notifications.
|
|
3
|
+
We want to add default monitoring of plc cycle usage to automatically disable notifications before
|
|
4
|
+
we are the reason of cycle time violations.
|
|
5
|
+
|
|
6
|
+
Future Features:
|
|
7
|
+
- Queueing of messages
|
|
8
|
+
- parallel parsing of notifications (plc tasks are faster as we can parse)
|
|
9
|
+
- QOS for queueing and parsing ?
|
|
10
|
+
|
|
11
|
+
"""
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
from typing import AsyncGenerator
|
|
19
|
+
from typing import Callable
|
|
20
|
+
from typing import Coroutine
|
|
21
|
+
|
|
22
|
+
from aioads.ads_symbol_cache import AdsSymbolCache
|
|
23
|
+
from aioads.ads_symbol_parser import AdsSymbolParser
|
|
24
|
+
from aioads.ams_address import AmsAddress
|
|
25
|
+
from aioads.ams_header import AmsHeader
|
|
26
|
+
from aioads.commands.ads_add_notification import (
|
|
27
|
+
AdsAddNotificationCommand, TransmissionMode
|
|
28
|
+
)
|
|
29
|
+
from aioads.commands.ads_delete_notification import AdsDeleteNotificationCommand
|
|
30
|
+
from aioads.functions.ads_symbol_info_by_name_ex import SymbolInfo
|
|
31
|
+
from aioads.stream import AdsStream
|
|
32
|
+
from aioads.transport import ITransport
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class NotificationValue:
|
|
37
|
+
"""
|
|
38
|
+
The callback value for parsed notifications
|
|
39
|
+
"""
|
|
40
|
+
handle: int
|
|
41
|
+
timestamp: int
|
|
42
|
+
value: Any
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
TNotificationCallback = Callable[[
|
|
46
|
+
list[NotificationValue]], Coroutine[None, None, None]]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NotificationManager:
|
|
50
|
+
"""
|
|
51
|
+
The notification manager is still in progress,
|
|
52
|
+
the notification manager is responsible to ensure a proper cleanup of all
|
|
53
|
+
not longer used notification subscriptions and distribution of parsed notifications
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
MAX_SUBSCRIPTIONS = 100 # Beckhoff PLCs support up to 500 notifications
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
transport: ITransport,
|
|
61
|
+
dst_address: AmsAddress,
|
|
62
|
+
symbol_cache: AdsSymbolCache,
|
|
63
|
+
parser: AdsSymbolParser,
|
|
64
|
+
):
|
|
65
|
+
self._logger = logging.getLogger(__name__)
|
|
66
|
+
self.transport = transport
|
|
67
|
+
self.transport.set_notification_callback(self.on_notification_received)
|
|
68
|
+
self.dst_address = dst_address
|
|
69
|
+
self.symbol_cache = symbol_cache
|
|
70
|
+
self.symbol_parser = parser
|
|
71
|
+
self._callback_handles: dict[int,
|
|
72
|
+
tuple[TNotificationCallback, SymbolInfo]] = {}
|
|
73
|
+
self._remove_tasks: dict[int, asyncio.Task] = {}
|
|
74
|
+
|
|
75
|
+
async def _create_notification(
|
|
76
|
+
self,
|
|
77
|
+
symbol_info: SymbolInfo,
|
|
78
|
+
mode: TransmissionMode,
|
|
79
|
+
cycle_time: int,
|
|
80
|
+
max_delay: int,
|
|
81
|
+
) -> int:
|
|
82
|
+
"""
|
|
83
|
+
Add a notification and return the id of the new created handle
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
function = AdsAddNotificationCommand(
|
|
87
|
+
transport=self.transport,
|
|
88
|
+
ams_address=self.dst_address,
|
|
89
|
+
idx_group=symbol_info.idx_group,
|
|
90
|
+
idx_offset=symbol_info.idx_offset,
|
|
91
|
+
length=symbol_info.idx_length,
|
|
92
|
+
transmission_mode=mode,
|
|
93
|
+
max_delay=max_delay,
|
|
94
|
+
cycle_time=cycle_time,
|
|
95
|
+
)
|
|
96
|
+
response = await function.request()
|
|
97
|
+
self._logger.info(
|
|
98
|
+
"Created notification handle %d for symbol %s",
|
|
99
|
+
response.notification_handle,
|
|
100
|
+
symbol_info.symbol_name,
|
|
101
|
+
)
|
|
102
|
+
return response.notification_handle
|
|
103
|
+
|
|
104
|
+
async def _remove_notification(self, notification_handle: int) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Remove a notification by the handle
|
|
107
|
+
"""
|
|
108
|
+
function = AdsDeleteNotificationCommand(
|
|
109
|
+
transport=self.transport,
|
|
110
|
+
ams_address=self.dst_address,
|
|
111
|
+
notification_handle=notification_handle,
|
|
112
|
+
)
|
|
113
|
+
await function.request()
|
|
114
|
+
self._remove_tasks.pop(notification_handle, None)
|
|
115
|
+
self._logger.info(
|
|
116
|
+
"Removed notification handle %d scheduled size %d",
|
|
117
|
+
notification_handle,
|
|
118
|
+
len(self._remove_tasks),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def parse_notification_payload(
|
|
122
|
+
self, payload: AdsStream
|
|
123
|
+
) -> AsyncGenerator[tuple[int, int, Any], None]:
|
|
124
|
+
"""
|
|
125
|
+
Handle the parsing of the notifications
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
unknown_handles: set[int] = set()
|
|
129
|
+
|
|
130
|
+
_length = int.from_bytes(payload.read(4), "little")
|
|
131
|
+
stamp_count = int.from_bytes(payload.read(4), "little")
|
|
132
|
+
|
|
133
|
+
for _ in range(stamp_count):
|
|
134
|
+
timestamp = int.from_bytes(payload.read(8), "little")
|
|
135
|
+
samples = int.from_bytes(payload.read(4), "little")
|
|
136
|
+
for _ in range(samples):
|
|
137
|
+
notification_handle = int.from_bytes(payload.read(4), "little")
|
|
138
|
+
sample_size = int.from_bytes(payload.read(4), "little")
|
|
139
|
+
handler = self._callback_handles.get(notification_handle)
|
|
140
|
+
if not handler:
|
|
141
|
+
unknown_handles.add(notification_handle)
|
|
142
|
+
# Skip unknown notification
|
|
143
|
+
payload.seek(payload.tell() + sample_size)
|
|
144
|
+
continue
|
|
145
|
+
_, symbol_info = handler
|
|
146
|
+
value = self.symbol_parser.parse(
|
|
147
|
+
symbol_info.data_type, symbol_info.type_name, payload
|
|
148
|
+
)
|
|
149
|
+
yield notification_handle, timestamp, value
|
|
150
|
+
|
|
151
|
+
for handle in unknown_handles:
|
|
152
|
+
self._logger.warning(
|
|
153
|
+
"Received notification for unknown handle %d",
|
|
154
|
+
handle,
|
|
155
|
+
)
|
|
156
|
+
await self._remove_notification(handle)
|
|
157
|
+
|
|
158
|
+
async def on_notification_received(self, _ams_header: AmsHeader, payload: AdsStream) -> None:
|
|
159
|
+
"""
|
|
160
|
+
handler for the raw notification events that need parsing and distribution
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
notifications: list[NotificationValue] = []
|
|
164
|
+
|
|
165
|
+
async for handle, timestamp, value in self.parse_notification_payload(payload):
|
|
166
|
+
notifications.append(NotificationValue(handle, timestamp, value))
|
|
167
|
+
|
|
168
|
+
grouped_by_handle: dict[int,
|
|
169
|
+
list[NotificationValue]] = defaultdict(list)
|
|
170
|
+
for notification in notifications:
|
|
171
|
+
grouped_by_handle[notification.handle].append(notification)
|
|
172
|
+
|
|
173
|
+
async with asyncio.TaskGroup() as tg:
|
|
174
|
+
for handle, notification_group in grouped_by_handle.items():
|
|
175
|
+
callback, _ = self._callback_handles[handle]
|
|
176
|
+
tg.create_task(callback(notification_group))
|
|
177
|
+
|
|
178
|
+
@asynccontextmanager
|
|
179
|
+
async def create_notification(
|
|
180
|
+
self,
|
|
181
|
+
symbol_name: str,
|
|
182
|
+
callback: TNotificationCallback,
|
|
183
|
+
mode: TransmissionMode,
|
|
184
|
+
cycle_time: int,
|
|
185
|
+
max_delay: int,
|
|
186
|
+
) -> AsyncGenerator[int, None]:
|
|
187
|
+
"""contextmanager to subscribe to variables that ensures we always remove
|
|
188
|
+
the unstd subscriptions.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
if len(self._callback_handles) >= self.MAX_SUBSCRIPTIONS:
|
|
192
|
+
raise ValueError("Maximum number of notifications reached")
|
|
193
|
+
|
|
194
|
+
symbol_info = await self.symbol_cache.read_symbol_info_by_name(symbol_name)
|
|
195
|
+
notification_handle = await self._create_notification(
|
|
196
|
+
symbol_info, mode, cycle_time, max_delay
|
|
197
|
+
)
|
|
198
|
+
try:
|
|
199
|
+
self._callback_handles[notification_handle] = (
|
|
200
|
+
callback, symbol_info)
|
|
201
|
+
yield notification_handle
|
|
202
|
+
finally:
|
|
203
|
+
await self._remove_notification(notification_handle)
|