Skip to content

Node API

o6\ gives you direct, pythonic access to OPC UA nodes and data types through the object model exposed by a connected client.

value = client.root.objects.MyVariable() # reads MyVariable on connected server

The Node API is more than a service-based wrapper. You can navigate an OPC UA server's address space — including both nodes and type definitions — using Python's regular attribute, subscript, and call operators. Those operators are translated into service requests like Browse, Read, Write, Call, letting you write expressions like the one above without ever constructing a request object.

A taste

from o6 import Client

with Client("opc.tcp://localhost:4840") as client:
    print(client.objects.MyInteger())              # read
    client.objects.MyInteger(42)                   # write
    print(client.objects.TestMethods.Hello("o6")) # call

Motivation

A service-based API is centered around OPC UA requests and response objects: building ReadRequests, sending them, and parsing the result. This is powerful and explicit, but it is also low-level and often very verbose.

The Node API is a higher-level, object-oriented layer on top of the OPC UA services.

Service API Node API
client.serviceRead(...) client.root.objects.MyVariable.read()
client.serviceWrite(...) client.root.objects.MyVariable(123)
client.serviceCall(...) client.objects.MyMethod.call(...)

The . syntax in the Node API gives you canonical Python access to the server's actual UA objects, wrapped as Python node objects. This blends OPC UA's object-oriented model with Python's own object orientation so your code feels natural and easy to follow.

  • It reduces boilerplate compared to manually constructing service requests.
  • It makes address-space traversal readable and discoverable.
  • It preserves OPC UA semantics while fitting naturally into Python code.
  • It gives a smoother onboarding path for application developers who want the flexibility of OPC UA without leaving Python's object model.

The Node API is the bridge between OPC UA's object-oriented server model and Python's own object orientation.

Easy access to OPC UA nodes from a client

Servers expose server.root, server.objects, server.types, server.views, and typed server[nodeId] lookup as local entry points into the same Node API. See Finding existing nodes for canonical server identity, lifecycle, and synchronous/asynchronous lookup.

When a client has connected, it exposes the server entry points as Python objects:

  • client.root
  • client.objects
  • client.types
  • client.views

These objects are live views into the server. The dotted attribute syntax represents the actual OPC UA nodes in the server address space, wrapped in Python node objects. Each path component is resolved on demand through a Browse request. Client handles cache child names and retry a cached miss with one fresh browse. Client node/reference mutations, BrowseName writes, reconnects, and updateRemoteNamespaces() invalidate navigation caches; a complete client.browse(node) invalidates that node's cache, including other handles for the same NodeId.

Changes made by another client or by the server do not automatically invalidate cached hits. Call client.invalidateNodeCache(node) to invalidate one parent's children, or client.invalidateNodeCache() for all existing handles. The next navigation browses again; async callers must await invalidation. Attribute reads and writes always go to the server.

from o6 import Client

client = Client("opc.tcp://localhost:4840")
client.connect()

node = client.root.objects.MyVariable

# The node object supports call-style to access attributes
print(node())                     # read the node value
print(node(attr="BrowseName"))    # read a specific attribute by name
# or use the OPC UA AttributeId enum
# print(node(attr=o6.AttributeId.BROWSE_NAME))
node(123)                         # write a new value to the node

Expected output:

np.int32(42)
o6.QualifiedName('1:MyVariable')

In the example above, client.root.objects.MyVariable is a direct path into the server's object hierarchy. The node returned behaves like a Python object, while still representing the underlying UA node.

Important: a node object can be safely cached for later use, only when we are sure the server model remains unchanged and the node is not being removed or renamed on the server side.

Usage

This page is a hands-on tour of the Node API once a client is connected. It assumes you have an OPC UA server reachable at opc.tcp://localhost:4840.

Setting the stage

A running example is the fastest way to learn. The snippets below assume a server that exposes an object with a few variables and a method, similar to the example server used by the tutorials:

Objects/
├── MyVariables      (Object)
│   ├── MyInteger    (Variable, Int32, read/write)
│   └── MyString     (Variable, String, read/write)
└── TestMethods      (Object)
    └── Hello        (Method, input: String, output: String)

The exact path is irrelevant — replace it with the structure of your own server. What matters is the three navigation patterns you'll see below: . to browse, [] to look up by path, () to act on a node.

Connecting

A Node-API session is a regular Client session. The Node API is layered on top of an active client connection.

from o6 import Client

client = Client("opc.tcp://localhost:4840")
client.connect()

Once client.connect() returns, the client has an active session and the entry-point nodes (client.root, client.objects, client.types, client.views) are live and ready to be navigated.


Browsing

Browsing is how you navigate from a node to its children. Two syntaxes are available: dotted attribute access for the common case, and bracket indexing for paths that are not safe as identifiers.

Entry points

A connected Client exposes four top-level nodes that correspond to the four standard folders of the OPC UA address space:

Attribute NodeId What's inside
client.root i=84 The address-space root. The three folders below are also reachable from here.
client.objects i=85 The instance address space — your application's objects, variables, methods.
client.types i=86 The type hierarchy — ObjectTypes, VariableTypes, ReferenceTypes, DataTypes.
client.views i=87 Server-defined views over the address space, if any.

Dot syntax

Every entry point is an ObjectNode that supports the same dot, bracket, and call syntaxes as any other node. Most user code starts from client.objects; the others are useful when you need to walk the type hierarchy or query a server-defined view.

. triggers a server-side Browse for the named child. Children are resolved lazily and cached on the parent node, so repeated access to the same child is free.

# A single step — resolve one child of Objects
parent = client.objects.MyVariables

# Chained steps — each `.` resolves one level
var = client.objects.MyVariables.MyInteger

# We can continue from the cached parent
var = parent.MyInteger

Names are matched case-insensitively against BrowseName. The result is a Node subclass (VariableNode, ObjectNode, MethodNode, …) depending on the child's NodeClass.

In interactive shells, dir(node) and <TAB> completion both run a browse and list the children — see Interactive completion below.

Bracket syntax []

The dot syntax fails when a BrowseName is ambiguous: if a node has more than one child with the same name (case-insensitively), node.child raises because the path does not pin it down to a single target. In these cases you have to use [], which returns a list of matching targets that you can disambiguate yourself.

# Two children both called "Status" → dot syntax can't pick one
nodes = client.objects["MyDevice/Status"]    # list of targets
status = nodes[0]                            # pick the right one

[] is translated server-side through TranslateBrowsePathsToNodeIds and handles the full range of the browse path syntax specified in the spcefication here.


Node access — the () operator

Once you have a node, the python call operator () handles every interaction: read, write, call. The distinction is by argument shape, not by a different method.

What () does

Every Node subclass overloads __call__. Calling a node with no value is a read, calling it with a value is a write, and calling a MethodNode is a call. An optional attr= keyword selects which attribute to act on; None (the default) means the Value attribute for variables and the method invocation for methods.

# Read
node()

# Read a specific attribute
node(attr="BrowseName")
node(attr=o6.AttributeId.DISPLAY_NAME)

# Write
node(42)
node("hello")

# Call (method nodes only)
parent.MyMethod("arg1", "arg2")

The attr= keyword accepts either an o6.AttributeId member or a case-insensitive o6.AttributeId-like string such as "BrowseName", "NodeClass", "DisplayName", "Description", "DataType", "ValueRank", "AccessLevel", …

Reading values

Call with no argument to read the current value of a variable:

value = client.objects.MyVariables.MyInteger()
print(value)

Reading a different attribute:

name = client.objects.MyVariables.MyInteger(attr="BrowseName")
print(name)             # QualifiedName('1:MyInteger')

class_ = client.objects.MyVariables.MyInteger(attr="NodeClass")

Writing values

Pass the new value as the first positional argument:

client.objects.MyVariables.MyInteger(42)
client.objects.MyVariables.MyString("hello")

Writing a non-Value attribute: pass both value= and attr=:

client.objects.MyVariables.MyInteger(value="NewName", attr="BrowseName")

This is equivalent to client.write(nodeid, value) on the high-level client API — but the node is reached through the address-space path, so no NodeId string is required.

Calling methods

Method nodes are called by passing the input arguments positionally. When the method is reached through its parent object, the parent is picked up automatically:

result = client.objects.TestMethods.Hello("World")
print(result)           # (StatusCode.GOOD, "Hello, World!")

Typed Method calls return (StatusCode, *output_arguments) on success and raise o6.StatusCodeError for a failed method or service. This applies to local and remote handles. Remote handles now check method statuses explicitly. Use client.call() or server.call() to receive bad method statuses in the tuple; those APIs still raise for service failures.

The Object is part of an OPC UA Call; it is not intrinsic to Method identity. Dot lookup returns a lightweight bound Method containing both the Object used for that lookup and the Method node. A Method obtained directly by NodeId has no lookup context, so pass the Object explicitly with object=. It can be an Object node or a NodeId-like value:

method = client[helloMethodNodeId]
result = method("World", object=client.objects.TestMethods)

addReference only adds an address-space edge. It does not change which callback a Method uses or mutate the Method node. If the same Method is referenced from several Objects, every dot lookup receives its own binding and therefore calls it with the correct Object.

Methods that take no inputs are invoked with an empty call:

client.objects.TestMethods.GetCurrentTime()

Async — one rule covers everything

In async tasks, calls on both client and server nodes are always awaitable, including local reads and writes that complete immediately. Sync and async share the same syntax — just add or drop the await:

import asyncio
from o6 import Client

async def main():
    client = Client("opc.tcp://localhost:4840")
    await client.connect()

    # Read
    value = await client.objects.MyVariables.MyInteger()

    # Write
    await client.objects.MyVariables.MyInteger(42)

    # Call
    greeting = await client.objects.TestMethods.Hello("World")

    # Browse (also awaitable)
    node = await client.objects.MyVariables.MyInteger

    await client.disconnect()

asyncio.run(main())

Under the hood, a sync call on a Client schedules a coroutine on the client's background loop and blocks the caller; an await on the same expression suspends until the result is available. The dot syntax, bracket syntax, and call syntax are identical in both modes — only the await keyword changes.


Firing events — the << operator (server-side)

The three operators above work from a Client or a Server. A fourth, <<, is server-only: on an ObjectNode or ViewNode handle, it fires an event sourced at that node without going back to the owning Server object:

pump << o6.Event(server, message="Cavitation detected")
pump << {"message": "Cavitation detected", "severity": 400}   # a plain mapping works too

<< is only defined for Object and View nodes — the two NodeClasses OPC UA defines an EventNotifier attribute for — so using it on any other node class raises TypeError immediately, naming the reason. The same is true for a node obtained from a Client, since local event creation is a server-side operation.

This section is not the full picture — see Events for eventType inference, reusable event drafts, and the parentheses required when awaiting << on an externally-driven event loop.


Interactive completion

The Node API is designed to be driven from a Python REPL (the standard >>> prompt, IPython, or any IDE / notebook that uses Jedi for completion):

  • client.objects.<TAB> shows the children of the Objects folder.
  • After a child is resolved, <TAB> on it shows its children, and so on.
  • dir(node) returns the same names explicitly.
  • Type hints on Node (via the bundled .pyi stubs) let your editor autocomplete the attr= keyword, the value= keyword for writes, and the available o6.AttributeId values.
>>> from o6 import Client
>>> client = Client("opc.tcp://localhost:4840")
>>> client.connect()
>>> client.objects.<TAB>
# Lists the children of the Objects folder:
#   MyVariables  TestMethods  Server  ...
>>> client.objects.MyVariables.<TAB>
# Lists the children of the MyVariables object:
#   MyInteger  MyString  ...
>>> client.objects.MyVariables.MyInteger.<TAB>
# Lists the Node API members on the resolved node:
#   nodeid  value  ...
>>> client.objects.MyVariables.MyInteger(attr=<TAB>
# Lists the AttributeId enum members:
#   NODEID  NODECLASS  BROWSENAME  DISPLAYNAME  VALUE  ...

The dotted navigation fetches and caches child nodes on demand; the first <TAB> after connecting pays a single Browse round-trip, subsequent <TAB> presses on the same node are served from the cache.

dir(node) exposes Python aliases for child BrowseNames. Instance aliases use the same spelling rules as generated model members: My Variable becomes mySpaceVariable, My-Variable becomes myMinusVariable, 3DPosition becomes threeDPosition, and Class becomes class_. Type nodes keep class-style names, replacing punctuation with underscores, for example My Type becomes My_Type. Aliases that would start with _ receive a node prefix (Node for types), so they remain accessible without looking up private Python attributes. Unicode aliases are normalized to the identifier spelling Python uses in source code.

The original BrowseName is unchanged and remains usable through getattr or a namespace-qualified browse path (namespace index 1 in this example):

client.objects.mySpaceVariable()          # Read using the Python alias.
getattr(client.objects, "My Variable")()  # Read using the original BrowseName.
client.objects["/1:My Variable"][0]()      # Resolve by browse path and read.

Exact original names take precedence; otherwise lookup matches original names and aliases case-insensitively. Collisions, such as My Variable and MySpaceVariable both appearing as mySpaceVariable, raise AttributeError instead of selecting an arbitrary child. Use an exact original name or a browse path to disambiguate. Existing Python attributes retain normal precedence over child lookup; browse paths remain available when those attributes hide a child.

On the client's owning event loop, dir(node) lists cached children and Python attributes without making a remote request. Use await client.browse(...) for fresh discovery in that context.

Tip: dir(node) returns a list synchronously, even though the children are resolved from the server. It is implemented with asyncio.run_coroutine_threadsafe against the client's background loop, so it works in a regular interactive shell without await.


Putting it together

from o6 import Client

with Client("opc.tcp://localhost:4840") as client:
    # browse (dot and bracket)
    var = client.objects.MyVariables.MyInteger
    method = client.objects["TestMethods/Hello"]

    # read
    print("before:", var())

    # write
    var(7)

    # read again
    print("after: ", var())

    # call
    greeting = method("o6")
    print("call:  ", greeting)

Expected output (values will differ):

before: 0
after:  7
call:   Hello, o6!

Quick reference

Operation Node API Equivalent service call
Browse by name node.child Browse
Translate path node["a/b/c"] TranslateBrowsePathsToNodeIds
Read value node() Read of Value
Read attribute node(attr="BrowseName") Read of the given attribute
Write value node(42) Write of Value
Write attribute node(value=..., attr=...) Write of the given attribute
Read via property var.value Read of Value
Call method parent.MyMethod(arg1, arg2) Call
Async variant await … (same syntax) —
List children dir(node) / <TAB> Browse
Fire an event (server-side) node << o6.Event(...) —

For the underlying service calls (when you need full control over the request payload), see the Client and Service API reference.