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 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)