Skip to content

Commit cc874ed

Browse files
authored
Merge pull request #1706 from roboflow/opc-ua-improvements
OPC-UA improvements for ignition
2 parents e951d5f + d09e7d6 commit cc874ed

File tree

2 files changed

+331
-25
lines changed

2 files changed

+331
-25
lines changed

inference/enterprise/workflows/enterprise_blocks/sinks/opc_writer/v1.py

Lines changed: 141 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@
4343
4444
**Note:** The data type you send must match the expected type of the target OPC UA variable.
4545
46+
### Node Lookup Mode
47+
The block supports two methods for locating OPC UA nodes via the `node_lookup_mode` parameter:
48+
49+
- **`hierarchical` (default)**: Uses standard OPC UA hierarchical path navigation. The block navigates
50+
through the address space using `get_child()`. Each component in the `object_name` path is
51+
automatically prefixed with the namespace index.
52+
- **Example**: `object_name="Roboflow/Crane_11"` → path `0:Objects/2:Roboflow/2:Crane_11/2:Variable`
53+
- **Best for**: Traditional OPC UA servers with hierarchical address spaces
54+
55+
- **`direct`**: Uses direct NodeId string access. The block constructs a NodeId as
56+
`ns={namespace};s={object_name}/{variable_name}` and accesses it directly via `get_node()`.
57+
- **Example**: `object_name="[Sample_Tags]/Ramp"` → NodeId `ns=2;s=[Sample_Tags]/Ramp/South_Person_Count`
58+
- **Best for**: Ignition SCADA systems and other servers using string-based NodeId identifiers
59+
4660
### Cooldown
4761
To prevent excessive traffic to the OPC UA server, the block includes a `cooldown_seconds` parameter,
4862
which defaults to **5 seconds**. During the cooldown period:
@@ -190,6 +204,15 @@ class BlockManifest(WorkflowBlockManifest):
190204
},
191205
examples=[10, "$inputs.cooldown_seconds"],
192206
)
207+
node_lookup_mode: Union[
208+
Selector(kind=[STRING_KIND]),
209+
Literal["hierarchical", "direct"],
210+
] = Field(
211+
default="hierarchical",
212+
description="Method to locate the OPC UA node: 'hierarchical' uses path navigation, "
213+
"'direct' uses NodeId strings (for Ignition-style string-based tags).",
214+
examples=["hierarchical", "direct"],
215+
)
193216

194217
@classmethod
195218
def describe_outputs(cls) -> List[OutputDefinition]:
@@ -238,8 +261,10 @@ def run(
238261
fire_and_forget: bool = True,
239262
disable_sink: bool = False,
240263
cooldown_seconds: int = 5,
264+
node_lookup_mode: Literal["hierarchical", "direct"] = "hierarchical",
241265
) -> BlockResult:
242266
if disable_sink:
267+
logging.debug("OPC Writer disabled by disable_sink parameter")
243268
return {
244269
"disabled": True,
245270
"throttling_status": False,
@@ -261,6 +286,7 @@ def run(
261286
}
262287

263288
value_str = str(value)
289+
logging.debug(f"OPC Writer converting value '{value_str}' to type {value_type}")
264290
try:
265291
if value_type in [BOOLEAN_KIND, "Boolean"]:
266292
decoded_value = value_str.strip().lower() in ("true", "1")
@@ -272,7 +298,9 @@ def run(
272298
decoded_value = value_str
273299
else:
274300
raise ValueError(f"Unsupported value type: {value_type}")
301+
logging.debug(f"OPC Writer successfully converted value to {decoded_value}")
275302
except ValueError as exc:
303+
logging.error(f"OPC Writer failed to convert value: {exc}")
276304
return {
277305
"disabled": False,
278306
"error_status": True,
@@ -290,9 +318,11 @@ def run(
290318
variable_name=variable_name,
291319
value=decoded_value,
292320
timeout=timeout,
321+
node_lookup_mode=node_lookup_mode,
293322
)
294323
self._last_notification_fired = datetime.now()
295324
if fire_and_forget and self._background_tasks:
325+
logging.debug("OPC Writer submitting write task to background tasks")
296326
self._background_tasks.add_task(opc_writer_handler)
297327
return {
298328
"disabled": False,
@@ -301,14 +331,19 @@ def run(
301331
"message": "Writing to the OPC UA server in the background task",
302332
}
303333
if fire_and_forget and self._thread_pool_executor:
334+
logging.debug("OPC Writer submitting write task to thread pool executor")
304335
self._thread_pool_executor.submit(opc_writer_handler)
305336
return {
306337
"disabled": False,
307338
"error_status": False,
308339
"throttling_status": False,
309340
"message": "Writing to the OPC UA server in the background task",
310341
}
342+
logging.debug("OPC Writer executing synchronous write")
311343
error_status, message = opc_writer_handler()
344+
logging.debug(
345+
f"OPC Writer write completed: error_status={error_status}, message={message}"
346+
)
312347
return {
313348
"disabled": False,
314349
"error_status": error_status,
@@ -317,6 +352,30 @@ def run(
317352
}
318353

319354

355+
def get_available_namespaces(client: Client) -> List[str]:
356+
"""
357+
Get list of available namespaces from OPC UA server.
358+
Returns empty list if unable to fetch namespaces.
359+
"""
360+
try:
361+
get_namespace_array = sync_async_client_method(AsyncClient.get_namespace_array)(
362+
client
363+
)
364+
return get_namespace_array()
365+
except Exception as exc:
366+
logging.info(f"Failed to get namespace array (non-fatal): {exc}")
367+
return ["<unable to fetch namespaces>"]
368+
369+
370+
def safe_disconnect(client: Client) -> None:
371+
"""Safely disconnect from OPC UA server, swallowing any errors"""
372+
try:
373+
logging.debug("OPC Writer disconnecting from server")
374+
client.disconnect()
375+
except Exception as exc:
376+
logging.debug(f"OPC Writer disconnect error (non-fatal): {exc}")
377+
378+
320379
def opc_connect_and_write_value(
321380
url: str,
322381
namespace: str,
@@ -326,7 +385,11 @@ def opc_connect_and_write_value(
326385
variable_name: str,
327386
value: Union[bool, float, int, str],
328387
timeout: int,
388+
node_lookup_mode: Literal["hierarchical", "direct"] = "hierarchical",
329389
) -> Tuple[bool, str]:
390+
logging.debug(
391+
f"OPC Writer attempting to connect and write value={value} to {url}/{object_name}/{variable_name}"
392+
)
330393
try:
331394
_opc_connect_and_write_value(
332395
url=url,
@@ -337,9 +400,14 @@ def opc_connect_and_write_value(
337400
variable_name=variable_name,
338401
value=value,
339402
timeout=timeout,
403+
node_lookup_mode=node_lookup_mode,
404+
)
405+
logging.debug(
406+
f"OPC Writer successfully wrote value to {url}/{object_name}/{variable_name}"
340407
)
341408
return False, "Value set successfully"
342409
except Exception as exc:
410+
logging.error(f"OPC Writer failed to write value: {exc}")
343411
return (
344412
True,
345413
f"Failed to write {value} to {object_name}:{variable_name} in {url}. Internal error details: {exc}.",
@@ -355,21 +423,28 @@ def _opc_connect_and_write_value(
355423
variable_name: str,
356424
value: Union[bool, float, int, str],
357425
timeout: int,
426+
node_lookup_mode: Literal["hierarchical", "direct"] = "hierarchical",
358427
):
428+
logging.debug(f"OPC Writer creating client for {url} with timeout={timeout}")
359429
client = Client(url=url, sync_wrapper_timeout=timeout)
360430
if user_name and password:
361431
client.set_user(user_name)
362432
client.set_password(password)
363433
try:
434+
logging.debug(f"OPC Writer connecting to {url}")
364435
client.connect()
436+
logging.debug("OPC Writer successfully connected to server")
365437
except BadUserAccessDenied as exc:
366-
client.disconnect()
438+
logging.error(f"OPC Writer authentication failed: {exc}")
439+
safe_disconnect(client)
367440
raise Exception(f"AUTH ERROR: {exc}")
368441
except OSError as exc:
369-
client.disconnect()
442+
logging.error(f"OPC Writer network error during connection: {exc}")
443+
safe_disconnect(client)
370444
raise Exception(f"NETWORK ERROR: {exc}")
371445
except Exception as exc:
372-
client.disconnect()
446+
logging.error(f"OPC Writer unhandled connection error: {type(exc)} {exc}")
447+
safe_disconnect(client)
373448
raise Exception(f"UNHANDLED ERROR: {type(exc)} {exc}")
374449
get_namespace_index = sync_async_client_method(AsyncClient.get_namespace_index)(
375450
client
@@ -378,33 +453,79 @@ def _opc_connect_and_write_value(
378453
try:
379454
if namespace.isdigit():
380455
nsidx = int(namespace)
456+
logging.debug(f"OPC Writer using numeric namespace index: {nsidx}")
381457
else:
382458
nsidx = get_namespace_index(namespace)
383459
except ValueError as exc:
384-
client.disconnect()
385-
raise Exception(f"WRONG NAMESPACE ERROR: {exc}")
386-
except Exception as exc:
387-
client.disconnect()
388-
raise Exception(f"UNHANDLED ERROR: {type(exc)} {exc}")
389-
390-
try:
391-
var = client.nodes.root.get_child(
392-
f"0:Objects/{nsidx}:{object_name}/{nsidx}:{variable_name}"
460+
namespaces = get_available_namespaces(client)
461+
logging.error(f"OPC Writer invalid namespace: {exc}")
462+
logging.error(f"Available namespaces: {namespaces}")
463+
safe_disconnect(client)
464+
raise Exception(
465+
f"WRONG NAMESPACE ERROR: {exc}. Available namespaces: {namespaces}"
393466
)
394-
except BadNoMatch as exc:
395-
client.disconnect()
396-
raise Exception(f"WRONG OBJECT OR PROPERTY ERROR: {exc}")
397467
except Exception as exc:
398-
client.disconnect()
399-
raise Exception(f"UNHANDLED ERROR: {type(exc)} {exc}")
468+
namespaces = get_available_namespaces(client)
469+
logging.error(f"OPC Writer unhandled namespace error: {type(exc)} {exc}")
470+
logging.error(f"Available namespaces: {namespaces}")
471+
safe_disconnect(client)
472+
raise Exception(
473+
f"UNHANDLED ERROR: {type(exc)} {exc}. Available namespaces: {namespaces}"
474+
)
475+
476+
if node_lookup_mode == "direct":
477+
# Direct NodeId access for Ignition-style string identifiers
478+
try:
479+
node_id = f"ns={nsidx};s={object_name}/{variable_name}"
480+
logging.debug(f"OPC Writer using direct NodeId access: {node_id}")
481+
var = client.get_node(node_id)
482+
# Verify the node exists by reading its attributes
483+
var.read_browse_name()
484+
logging.debug(
485+
f"OPC Writer successfully found variable node using direct NodeId"
486+
)
487+
except Exception as exc:
488+
logging.error(f"OPC Writer direct NodeId access failed: {exc}")
489+
safe_disconnect(client)
490+
raise Exception(
491+
f"WRONG OBJECT OR PROPERTY ERROR: Could not find node with direct NodeId '{node_id}'. Error: {exc}"
492+
)
493+
else:
494+
# Hierarchical path navigation (standard OPC UA)
495+
try:
496+
# Split object_name on "/" and prepend namespace index to each component
497+
object_components = object_name.split("/")
498+
object_path = "/".join([f"{nsidx}:{comp}" for comp in object_components])
499+
node_path = f"0:Objects/{object_path}/{nsidx}:{variable_name}"
500+
logging.debug(f"OPC Writer using hierarchical path: {node_path}")
501+
var = client.nodes.root.get_child(node_path)
502+
logging.debug(
503+
f"OPC Writer successfully found variable node using hierarchical path"
504+
)
505+
except BadNoMatch as exc:
506+
logging.error(f"OPC Writer hierarchical path not found: {exc}")
507+
safe_disconnect(client)
508+
raise Exception(
509+
f"WRONG OBJECT OR PROPERTY ERROR: Could not find node at hierarchical path '{node_path}'. Error: {exc}"
510+
)
511+
except Exception as exc:
512+
logging.error(f"OPC Writer unhandled node lookup error: {type(exc)} {exc}")
513+
safe_disconnect(client)
514+
raise Exception(f"UNHANDLED ERROR: {type(exc)} {exc}")
400515

401516
try:
517+
logging.debug(f"OPC Writer writing value '{value}' to variable")
402518
var.write_value(value)
519+
logging.info(
520+
f"OPC Writer successfully wrote '{value}' to variable at {object_name}/{variable_name}"
521+
)
403522
except BadTypeMismatch as exc:
404-
client.disconnect()
523+
logging.error(f"OPC Writer type mismatch error: {exc}")
524+
safe_disconnect(client)
405525
raise Exception(f"WRONG TYPE ERROR: {exc}")
406526
except Exception as exc:
407-
client.disconnect()
527+
logging.error(f"OPC Writer unhandled write error: {type(exc)} {exc}")
528+
safe_disconnect(client)
408529
raise Exception(f"UNHANDLED ERROR: {type(exc)} {exc}")
409530

410-
client.disconnect()
531+
safe_disconnect(client)

0 commit comments

Comments
 (0)