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.
- plexus/__init__.py +31 -0
- plexus/__main__.py +4 -0
- plexus/adapters/__init__.py +122 -0
- plexus/adapters/base.py +409 -0
- plexus/adapters/ble.py +257 -0
- plexus/adapters/can.py +439 -0
- plexus/adapters/can_detect.py +174 -0
- plexus/adapters/mavlink.py +642 -0
- plexus/adapters/mavlink_detect.py +192 -0
- plexus/adapters/modbus.py +622 -0
- plexus/adapters/mqtt.py +350 -0
- plexus/adapters/opcua.py +607 -0
- plexus/adapters/registry.py +206 -0
- plexus/adapters/serial_adapter.py +547 -0
- plexus/buffer.py +257 -0
- plexus/cameras/__init__.py +57 -0
- plexus/cameras/auto.py +239 -0
- plexus/cameras/base.py +189 -0
- plexus/cameras/picamera.py +171 -0
- plexus/cameras/usb.py +143 -0
- plexus/cli.py +783 -0
- plexus/client.py +465 -0
- plexus/config.py +169 -0
- plexus/connector.py +666 -0
- plexus/deps.py +246 -0
- plexus/detect.py +1238 -0
- plexus/importers/__init__.py +25 -0
- plexus/importers/rosbag.py +778 -0
- plexus/sensors/__init__.py +118 -0
- plexus/sensors/ads1115.py +164 -0
- plexus/sensors/adxl345.py +179 -0
- plexus/sensors/auto.py +290 -0
- plexus/sensors/base.py +412 -0
- plexus/sensors/bh1750.py +102 -0
- plexus/sensors/bme280.py +241 -0
- plexus/sensors/gps.py +317 -0
- plexus/sensors/ina219.py +149 -0
- plexus/sensors/magnetometer.py +239 -0
- plexus/sensors/mpu6050.py +162 -0
- plexus/sensors/sht3x.py +139 -0
- plexus/sensors/spi_scan.py +164 -0
- plexus/sensors/system.py +261 -0
- plexus/sensors/vl53l0x.py +109 -0
- plexus/streaming.py +743 -0
- plexus/tui.py +642 -0
- plexus_python-0.1.0.dist-info/METADATA +470 -0
- plexus_python-0.1.0.dist-info/RECORD +50 -0
- plexus_python-0.1.0.dist-info/WHEEL +4 -0
- plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
- plexus_python-0.1.0.dist-info/licenses/LICENSE +190 -0
plexus/adapters/opcua.py
ADDED
|
@@ -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
|
+
)
|