plexus-python 0.1.0__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.
Files changed (50) hide show
  1. plexus/__init__.py +31 -0
  2. plexus/__main__.py +4 -0
  3. plexus/adapters/__init__.py +122 -0
  4. plexus/adapters/base.py +409 -0
  5. plexus/adapters/ble.py +257 -0
  6. plexus/adapters/can.py +439 -0
  7. plexus/adapters/can_detect.py +174 -0
  8. plexus/adapters/mavlink.py +642 -0
  9. plexus/adapters/mavlink_detect.py +192 -0
  10. plexus/adapters/modbus.py +622 -0
  11. plexus/adapters/mqtt.py +350 -0
  12. plexus/adapters/opcua.py +607 -0
  13. plexus/adapters/registry.py +206 -0
  14. plexus/adapters/serial_adapter.py +547 -0
  15. plexus/buffer.py +257 -0
  16. plexus/cameras/__init__.py +57 -0
  17. plexus/cameras/auto.py +239 -0
  18. plexus/cameras/base.py +189 -0
  19. plexus/cameras/picamera.py +171 -0
  20. plexus/cameras/usb.py +143 -0
  21. plexus/cli.py +783 -0
  22. plexus/client.py +465 -0
  23. plexus/config.py +169 -0
  24. plexus/connector.py +666 -0
  25. plexus/deps.py +246 -0
  26. plexus/detect.py +1238 -0
  27. plexus/importers/__init__.py +25 -0
  28. plexus/importers/rosbag.py +778 -0
  29. plexus/sensors/__init__.py +118 -0
  30. plexus/sensors/ads1115.py +164 -0
  31. plexus/sensors/adxl345.py +179 -0
  32. plexus/sensors/auto.py +290 -0
  33. plexus/sensors/base.py +412 -0
  34. plexus/sensors/bh1750.py +102 -0
  35. plexus/sensors/bme280.py +241 -0
  36. plexus/sensors/gps.py +317 -0
  37. plexus/sensors/ina219.py +149 -0
  38. plexus/sensors/magnetometer.py +239 -0
  39. plexus/sensors/mpu6050.py +162 -0
  40. plexus/sensors/sht3x.py +139 -0
  41. plexus/sensors/spi_scan.py +164 -0
  42. plexus/sensors/system.py +261 -0
  43. plexus/sensors/vl53l0x.py +109 -0
  44. plexus/streaming.py +743 -0
  45. plexus/tui.py +642 -0
  46. plexus_python-0.1.0.dist-info/METADATA +470 -0
  47. plexus_python-0.1.0.dist-info/RECORD +50 -0
  48. plexus_python-0.1.0.dist-info/WHEEL +4 -0
  49. plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
  50. plexus_python-0.1.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,607 @@
1
+ """
2
+ OPC-UA Protocol Adapter - Bridge OPC-UA servers to Plexus
3
+
4
+ This adapter connects to OPC-UA servers, browses or reads configured nodes,
5
+ and forwards their values as Plexus metrics. Supports both polling and
6
+ subscription-based modes.
7
+
8
+ Requirements:
9
+ pip install plexus-python[opcua]
10
+ # or
11
+ pip install asyncua
12
+
13
+ Usage:
14
+ from plexus.adapters import OPCUAAdapter
15
+
16
+ # Basic usage - browse all nodes under Objects
17
+ adapter = OPCUAAdapter(endpoint="opc.tcp://localhost:4840")
18
+ adapter.connect()
19
+ for metric in adapter.poll():
20
+ print(f"{metric.name}: {metric.value}")
21
+
22
+ # Read specific nodes
23
+ adapter = OPCUAAdapter(
24
+ endpoint="opc.tcp://plc.factory.local:4840",
25
+ namespace=2,
26
+ node_ids=["Temperature", "Pressure", "FlowRate"],
27
+ poll_interval=0.5,
28
+ )
29
+
30
+ # With authentication and security
31
+ adapter = OPCUAAdapter(
32
+ endpoint="opc.tcp://secure-server:4840",
33
+ username="operator",
34
+ password="secret",
35
+ security_policy="Basic256Sha256",
36
+ )
37
+
38
+ # Subscription-based (push) mode
39
+ def handle_data(metrics):
40
+ for m in metrics:
41
+ print(f"{m.name}: {m.value}")
42
+
43
+ adapter = OPCUAAdapter(
44
+ endpoint="opc.tcp://localhost:4840",
45
+ node_ids=["Temperature", "Pressure"],
46
+ )
47
+ adapter.run(on_data=handle_data)
48
+
49
+ Emitted metrics:
50
+ - opcua.{NodeName} - Node value with optional engineering unit tag
51
+ - Custom prefix can be set via the prefix parameter
52
+
53
+ Requires: pip install asyncua
54
+ """
55
+
56
+ import asyncio
57
+ import logging
58
+ import time
59
+ from typing import Any, Dict, List, Optional
60
+
61
+ from plexus.adapters.base import (
62
+ ProtocolAdapter,
63
+ Metric,
64
+ AdapterConfig,
65
+ AdapterState,
66
+ ConnectionError,
67
+ ProtocolError,
68
+ )
69
+ from plexus.adapters.registry import AdapterRegistry
70
+
71
+ logger = logging.getLogger(__name__)
72
+
73
+ # Optional dependency — imported at module level so it can be mocked in tests
74
+ try:
75
+ from asyncua import Client as OPCUAClient
76
+ from asyncua import ua
77
+ except ImportError:
78
+ OPCUAClient = None # type: ignore[assignment, misc]
79
+ ua = None # type: ignore[assignment]
80
+
81
+
82
+ class OPCUAAdapter(ProtocolAdapter):
83
+ """
84
+ OPC-UA protocol adapter.
85
+
86
+ Connects to an OPC-UA server and reads node values as Plexus metrics.
87
+ Supports both polling mode (read nodes on demand) and subscription mode
88
+ (receive data change notifications via OPC-UA subscriptions).
89
+
90
+ In polling mode, call poll() to read the current values of all configured
91
+ nodes. If no node_ids are provided, the adapter browses children of the
92
+ Objects folder (ns=0;i=85) and reads all variable nodes it finds.
93
+
94
+ In subscription mode (via run()), the adapter creates an OPC-UA
95
+ subscription and monitors configured nodes for data changes, emitting
96
+ metrics through the callback as values change.
97
+
98
+ Args:
99
+ endpoint: OPC-UA server endpoint URL (e.g., "opc.tcp://localhost:4840")
100
+ namespace: Default namespace index for node IDs (default: 2)
101
+ node_ids: List of node identifier strings to read. If None, the adapter
102
+ browses the Objects folder for variable nodes.
103
+ username: Optional username for authentication
104
+ password: Optional password for authentication
105
+ security_policy: Optional security policy string (e.g., "Basic256Sha256").
106
+ When set, the connection uses Sign & Encrypt mode.
107
+ poll_interval: Seconds between polls / subscription publish interval
108
+ (default: 1.0)
109
+ prefix: Prefix prepended to all metric names (default: "opcua.")
110
+ source_id: Optional source identifier attached to all emitted metrics
111
+
112
+ Example:
113
+ adapter = OPCUAAdapter(
114
+ endpoint="opc.tcp://localhost:4840",
115
+ namespace=2,
116
+ node_ids=["Temperature", "Pressure"],
117
+ )
118
+
119
+ with adapter:
120
+ while True:
121
+ for metric in adapter.poll():
122
+ print(f"{metric.name} = {metric.value}")
123
+ """
124
+
125
+ def __init__(
126
+ self,
127
+ endpoint: str = "opc.tcp://localhost:4840",
128
+ namespace: int = 2,
129
+ node_ids: Optional[List[str]] = None,
130
+ username: Optional[str] = None,
131
+ password: Optional[str] = None,
132
+ security_policy: Optional[str] = None,
133
+ poll_interval: float = 1.0,
134
+ prefix: str = "opcua.",
135
+ source_id: Optional[str] = None,
136
+ **kwargs,
137
+ ):
138
+ config = AdapterConfig(
139
+ name="opcua",
140
+ params={
141
+ "endpoint": endpoint,
142
+ "namespace": namespace,
143
+ "node_ids": node_ids,
144
+ "username": username,
145
+ "security_policy": security_policy,
146
+ "poll_interval": poll_interval,
147
+ "prefix": prefix,
148
+ **kwargs,
149
+ },
150
+ )
151
+ super().__init__(config)
152
+
153
+ self.endpoint = endpoint
154
+ self.namespace = namespace
155
+ self.node_ids = node_ids
156
+ self.username = username
157
+ self.password = password
158
+ self.security_policy = security_policy
159
+ self.poll_interval = poll_interval
160
+ self.prefix = prefix
161
+ self._source_id = source_id
162
+
163
+ self._client: Optional[Any] = None # asyncua.Client instance
164
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
165
+ self._resolved_nodes: List[Any] = [] # Resolved Node objects
166
+ self._node_names: Dict[str, str] = {} # node_id -> display name
167
+ self._node_units: Dict[str, str] = {} # node_id -> engineering unit
168
+ self._subscription: Optional[Any] = None
169
+ self._pending_metrics: List[Metric] = []
170
+
171
+ def validate_config(self) -> bool:
172
+ """Validate adapter configuration."""
173
+ if not self.endpoint:
174
+ raise ValueError("OPC-UA endpoint URL is required")
175
+ if not self.endpoint.startswith("opc.tcp://"):
176
+ raise ValueError(
177
+ f"Invalid OPC-UA endpoint: '{self.endpoint}'. "
178
+ "Must start with 'opc.tcp://'"
179
+ )
180
+ if self.namespace < 0:
181
+ raise ValueError("Namespace index must be non-negative")
182
+ if self.poll_interval <= 0:
183
+ raise ValueError("Poll interval must be positive")
184
+ return True
185
+
186
+ def _get_or_create_loop(self) -> asyncio.AbstractEventLoop:
187
+ """Get or create an event loop for running async code."""
188
+ if self._loop is not None and not self._loop.is_closed():
189
+ return self._loop
190
+ try:
191
+ loop = asyncio.get_running_loop()
192
+ except RuntimeError:
193
+ loop = asyncio.new_event_loop()
194
+ asyncio.set_event_loop(loop)
195
+ self._loop = loop
196
+ return loop
197
+
198
+ def _run_async(self, coro):
199
+ """Run an async coroutine synchronously."""
200
+ loop = self._get_or_create_loop()
201
+ if loop.is_running():
202
+ # We're inside an already-running loop (e.g., Jupyter).
203
+ # Create a new loop in this case.
204
+ import concurrent.futures
205
+ with concurrent.futures.ThreadPoolExecutor() as pool:
206
+ future = pool.submit(asyncio.run, coro)
207
+ return future.result()
208
+ return loop.run_until_complete(coro)
209
+
210
+ def connect(self) -> bool:
211
+ """
212
+ Connect to the OPC-UA server.
213
+
214
+ Creates an asyncua Client, optionally configures authentication and
215
+ security, connects, and resolves the target nodes.
216
+
217
+ Returns:
218
+ True if connection successful, False otherwise.
219
+
220
+ Raises:
221
+ ConnectionError: If asyncua is not installed or connection fails.
222
+ """
223
+ if OPCUAClient is None:
224
+ self._set_state(AdapterState.ERROR, "asyncua not installed")
225
+ raise ConnectionError(
226
+ "asyncua is required. Install with: pip install plexus-python[opcua] "
227
+ "or pip install asyncua"
228
+ )
229
+
230
+ try:
231
+ self._set_state(AdapterState.CONNECTING)
232
+ logger.info(f"Connecting to OPC-UA server: {self.endpoint}")
233
+ self._run_async(self._async_connect())
234
+ self._set_state(AdapterState.CONNECTED)
235
+ logger.info(
236
+ f"Connected to OPC-UA server: {self.endpoint} "
237
+ f"({len(self._resolved_nodes)} nodes resolved)"
238
+ )
239
+ return True
240
+
241
+ except Exception as e:
242
+ self._set_state(AdapterState.ERROR, str(e))
243
+ logger.error(f"Failed to connect to OPC-UA server: {e}")
244
+ raise ConnectionError(f"OPC-UA connection failed: {e}")
245
+
246
+ async def _async_connect(self) -> None:
247
+ """Async implementation of connect."""
248
+ self._client = OPCUAClient(url=self.endpoint)
249
+
250
+ # Authentication
251
+ if self.username:
252
+ self._client.set_user(self.username)
253
+ if self.password:
254
+ self._client.set_password(self.password)
255
+
256
+ # Security policy
257
+ if self.security_policy:
258
+ await self._client.set_security_string(
259
+ f"{self.security_policy},SignAndEncrypt"
260
+ )
261
+
262
+ await self._client.connect()
263
+
264
+ # Resolve nodes
265
+ await self._resolve_nodes()
266
+
267
+ async def _resolve_nodes(self) -> None:
268
+ """
269
+ Resolve configured node IDs to Node objects.
270
+
271
+ If node_ids were provided, resolve each one under the configured
272
+ namespace. Otherwise, browse the Objects folder and discover all
273
+ variable nodes.
274
+ """
275
+ self._resolved_nodes = []
276
+ self._node_names = {}
277
+ self._node_units = {}
278
+
279
+ if self.node_ids:
280
+ # Resolve explicitly configured nodes
281
+ for node_id in self.node_ids:
282
+ try:
283
+ node = self._client.get_node(
284
+ ua.NodeId(node_id, self.namespace)
285
+ )
286
+ display_name = await node.read_display_name()
287
+ name_text = display_name.Text if hasattr(display_name, 'Text') else str(display_name)
288
+ node_str = node.nodeid.to_string()
289
+
290
+ self._resolved_nodes.append(node)
291
+ self._node_names[node_str] = name_text
292
+
293
+ # Try to read engineering units
294
+ await self._read_engineering_unit(node, node_str)
295
+
296
+ logger.debug(f"Resolved node: {node_id} -> {name_text}")
297
+ except Exception as e:
298
+ logger.warning(f"Failed to resolve node '{node_id}': {e}")
299
+ else:
300
+ # Browse Objects folder for variable nodes
301
+ await self._browse_objects()
302
+
303
+ async def _browse_objects(self) -> None:
304
+ """Browse the Objects folder and find all readable variable nodes."""
305
+ objects_node = self._client.nodes.objects
306
+ await self._browse_recursive(objects_node, max_depth=3)
307
+
308
+ async def _browse_recursive(self, node, max_depth: int = 3, depth: int = 0) -> None:
309
+ """Recursively browse children of a node up to max_depth."""
310
+ if depth >= max_depth:
311
+ return
312
+
313
+ try:
314
+ children = await node.get_children()
315
+ except Exception:
316
+ return
317
+
318
+ for child in children:
319
+ try:
320
+ node_class = await child.read_node_class()
321
+ # ua.NodeClass.Variable == 2
322
+ if node_class == ua.NodeClass.Variable:
323
+ display_name = await child.read_display_name()
324
+ name_text = display_name.Text if hasattr(display_name, 'Text') else str(display_name)
325
+ node_str = child.nodeid.to_string()
326
+
327
+ self._resolved_nodes.append(child)
328
+ self._node_names[node_str] = name_text
329
+
330
+ await self._read_engineering_unit(child, node_str)
331
+
332
+ logger.debug(f"Discovered variable node: {name_text}")
333
+ elif node_class == ua.NodeClass.Object:
334
+ # Recurse into object nodes
335
+ await self._browse_recursive(child, max_depth, depth + 1)
336
+ except Exception as e:
337
+ logger.debug(f"Error browsing child node: {e}")
338
+
339
+ async def _read_engineering_unit(self, node, node_str: str) -> None:
340
+ """
341
+ Try to read the EngineeringUnits property of a node.
342
+
343
+ OPC-UA analog items may expose an EUInformation structure containing
344
+ a display name for the engineering unit (e.g., "degC", "bar").
345
+ """
346
+ try:
347
+ # EUInformation is typically in property node with BrowseName "EngineeringUnits"
348
+ eu_props = await node.get_properties()
349
+ for prop in eu_props:
350
+ browse_name = await prop.read_browse_name()
351
+ if browse_name.Name == "EngineeringUnits":
352
+ eu_value = await prop.read_value()
353
+ # EUInformation has a DisplayName field
354
+ if hasattr(eu_value, 'DisplayName') and eu_value.DisplayName:
355
+ unit_text = eu_value.DisplayName.Text
356
+ if unit_text:
357
+ self._node_units[node_str] = unit_text
358
+ break
359
+ except Exception:
360
+ # Engineering units are optional; silently skip
361
+ pass
362
+
363
+ def disconnect(self) -> None:
364
+ """Disconnect from the OPC-UA server."""
365
+ if self._client:
366
+ try:
367
+ self._run_async(self._async_disconnect())
368
+ logger.info("Disconnected from OPC-UA server")
369
+ except Exception as e:
370
+ logger.warning(f"Error disconnecting from OPC-UA server: {e}")
371
+ finally:
372
+ self._client = None
373
+ self._resolved_nodes = []
374
+ self._subscription = None
375
+
376
+ self._set_state(AdapterState.DISCONNECTED)
377
+
378
+ async def _async_disconnect(self) -> None:
379
+ """Async implementation of disconnect."""
380
+ if self._subscription:
381
+ try:
382
+ await self._subscription.delete()
383
+ except Exception:
384
+ pass
385
+ self._subscription = None
386
+
387
+ if self._client:
388
+ await self._client.disconnect()
389
+
390
+ def poll(self) -> List[Metric]:
391
+ """
392
+ Poll all resolved nodes and return their current values as metrics.
393
+
394
+ For each resolved node, reads the current value from the OPC-UA server
395
+ and creates a Metric with:
396
+ - name: prefix + node display name (e.g., "opcua.Temperature")
397
+ - value: the current node value
398
+ - tags: includes "unit" if engineering units are available
399
+
400
+ Returns:
401
+ List of Metric objects. Empty list if no nodes or not connected.
402
+ """
403
+ if not self._client or not self._resolved_nodes:
404
+ # Also drain any pending metrics from subscription mode
405
+ if self._pending_metrics:
406
+ metrics = self._pending_metrics.copy()
407
+ self._pending_metrics.clear()
408
+ return metrics
409
+ return []
410
+
411
+ try:
412
+ return self._run_async(self._async_poll())
413
+ except Exception as e:
414
+ logger.error(f"Error polling OPC-UA nodes: {e}")
415
+ raise ProtocolError(f"OPC-UA poll error: {e}")
416
+
417
+ async def _async_poll(self) -> List[Metric]:
418
+ """Async implementation of poll."""
419
+ metrics: List[Metric] = []
420
+ now = time.time()
421
+
422
+ for node in self._resolved_nodes:
423
+ try:
424
+ value = await node.read_value()
425
+ node_str = node.nodeid.to_string()
426
+ display_name = self._node_names.get(node_str, node_str)
427
+
428
+ # Build metric name
429
+ metric_name = f"{self.prefix}{display_name}"
430
+
431
+ # Build tags
432
+ tags: Dict[str, str] = {
433
+ "node_id": node_str,
434
+ }
435
+ unit = self._node_units.get(node_str)
436
+ if unit:
437
+ tags["unit"] = unit
438
+
439
+ # Coerce value to a type Metric supports
440
+ coerced = self._coerce_value(value)
441
+ if coerced is not None:
442
+ metrics.append(
443
+ Metric(
444
+ name=metric_name,
445
+ value=coerced,
446
+ timestamp=now,
447
+ tags=tags,
448
+ source_id=self._source_id,
449
+ )
450
+ )
451
+
452
+ except Exception as e:
453
+ logger.debug(f"Error reading node {node}: {e}")
454
+
455
+ return metrics
456
+
457
+ def _coerce_value(self, value: Any) -> Any:
458
+ """
459
+ Coerce an OPC-UA value to a type that Metric supports.
460
+
461
+ Handles numeric types, booleans, strings, lists, and dicts.
462
+ Returns None for unsupported types.
463
+ """
464
+ if isinstance(value, (int, float, bool, str)):
465
+ return value
466
+ if isinstance(value, (list, dict)):
467
+ return value
468
+ # numpy-like or OPC-UA numeric variant types
469
+ try:
470
+ return float(value)
471
+ except (TypeError, ValueError):
472
+ pass
473
+ try:
474
+ return str(value)
475
+ except Exception:
476
+ return None
477
+
478
+ def _run_loop(self) -> None:
479
+ """
480
+ Run loop using OPC-UA subscription-based mode.
481
+
482
+ Instead of polling, this creates an OPC-UA subscription that monitors
483
+ all resolved nodes for data changes. When a value changes on the server,
484
+ the subscription callback fires and emits metrics immediately.
485
+
486
+ Falls back to polling if subscription setup fails.
487
+ """
488
+ try:
489
+ self._run_async(self._async_subscription_loop())
490
+ except KeyboardInterrupt:
491
+ pass
492
+ except Exception as e:
493
+ logger.warning(
494
+ f"Subscription mode failed ({e}), falling back to polling"
495
+ )
496
+ self._polling_fallback()
497
+ finally:
498
+ self.disconnect()
499
+
500
+ async def _async_subscription_loop(self) -> None:
501
+ """Async implementation of subscription-based run loop."""
502
+ if not self._client or not self._resolved_nodes:
503
+ return
504
+
505
+ # Create subscription
506
+ handler = _SubscriptionHandler(self)
507
+ self._subscription = await self._client.create_subscription(
508
+ period=int(self.poll_interval * 1000), # ms
509
+ handler=handler,
510
+ )
511
+
512
+ # Subscribe to data changes for all resolved nodes
513
+ handles = await self._subscription.subscribe_data_change(
514
+ self._resolved_nodes
515
+ )
516
+ logger.info(
517
+ f"OPC-UA subscription active with {len(handles)} monitored items"
518
+ )
519
+
520
+ # Keep alive until disconnected
521
+ try:
522
+ while self._state == AdapterState.CONNECTED:
523
+ # Drain pending metrics from subscription handler
524
+ if self._pending_metrics:
525
+ metrics = self._pending_metrics.copy()
526
+ self._pending_metrics.clear()
527
+ if metrics:
528
+ self._emit_data(metrics)
529
+ self.on_data(metrics)
530
+ await asyncio.sleep(0.05)
531
+ except asyncio.CancelledError:
532
+ pass
533
+
534
+ def _polling_fallback(self) -> None:
535
+ """Fallback to simple polling if subscriptions are not supported."""
536
+ try:
537
+ while self.is_connected:
538
+ metrics = self.poll()
539
+ if metrics:
540
+ self._emit_data(metrics)
541
+ self.on_data(metrics)
542
+ time.sleep(self.poll_interval)
543
+ except KeyboardInterrupt:
544
+ pass
545
+
546
+ @property
547
+ def stats(self) -> Dict[str, Any]:
548
+ """Get adapter statistics including OPC-UA-specific info."""
549
+ base_stats = super().stats
550
+ base_stats.update({
551
+ "endpoint": self.endpoint,
552
+ "namespace": self.namespace,
553
+ "resolved_nodes": len(self._resolved_nodes),
554
+ "has_subscription": self._subscription is not None,
555
+ "poll_interval": self.poll_interval,
556
+ })
557
+ return base_stats
558
+
559
+
560
+ class _SubscriptionHandler:
561
+ """
562
+ Internal handler for OPC-UA subscription data change notifications.
563
+
564
+ When a monitored node's value changes, the OPC-UA client library calls
565
+ datachange_notification() on this handler. We convert the notification
566
+ into a Metric and append it to the adapter's pending metrics list.
567
+ """
568
+
569
+ def __init__(self, adapter: OPCUAAdapter):
570
+ self._adapter = adapter
571
+
572
+ def datachange_notification(self, node, val, data) -> None:
573
+ """Handle a data change notification from the OPC-UA subscription."""
574
+ try:
575
+ node_str = node.nodeid.to_string()
576
+ display_name = self._adapter._node_names.get(node_str, node_str)
577
+ metric_name = f"{self._adapter.prefix}{display_name}"
578
+
579
+ tags: Dict[str, str] = {"node_id": node_str}
580
+ unit = self._adapter._node_units.get(node_str)
581
+ if unit:
582
+ tags["unit"] = unit
583
+
584
+ coerced = self._adapter._coerce_value(val)
585
+ if coerced is not None:
586
+ metric = Metric(
587
+ name=metric_name,
588
+ value=coerced,
589
+ timestamp=time.time(),
590
+ tags=tags,
591
+ source_id=self._adapter._source_id,
592
+ )
593
+ self._adapter._pending_metrics.append(metric)
594
+
595
+ except Exception as e:
596
+ logger.debug(f"Error in subscription handler: {e}")
597
+
598
+
599
+ # Register the adapter
600
+ AdapterRegistry.register(
601
+ "opcua",
602
+ OPCUAAdapter,
603
+ description="OPC-UA client adapter for industrial automation servers",
604
+ author="Plexus",
605
+ version="1.0.0",
606
+ requires=["asyncua"],
607
+ )