Skip to content

Writing a Nodeset in Python

Compiling XML is one way to get a namespace. The other is to skip the XML entirely and write the nodeset directly in Python. The o6\\python decorators, emitted by the compiler, are intentionally designed as human readable syntax. A hand-written module and a generated package are the same shape, register the same way, and are indistinguishable to server.ns.append and to a client on the wire.

That makes this the natural path when the model is yours: an application-specific type system, a prototype you are still reshaping, a small vendor extension on top of a companion spec, or types generated at runtime from a device description.

Info

If you already have a *.NodeSet2.xml — from a companion spec, a vendor, or a modelling tool — compile it. See Compiling Nodesets.

Info

The declarations on this page mirror the spec's node classes and its DataType kinds. For what a DataType node carries, and how structured types, enumerations and OptionSets differ, see DataType in OPC UA Fundamentals.


The shape of a namespace module

A namespace module is an ordinary Python module. Two things make it a nodeset:

  1. A call to o6.ns.namespace(...) at the top, which registers the namespace in the process-wide o6.ns table and records it in the module's __NAMESPACES__.
  2. Decorated classes in the module body — one per type.
# plant.py
from typing import Optional

import o6
from o6.ns import ns0

o6.ns.namespace("plant", uri="http://example.org/Plant/", version="1.0")


@o6.enumtype(ns="plant", description="Machine state")
class MachineState:
    IDLE = 0
    RUNNING = 1
    FAULT = 2

server.ns.append then publishes it exactly like a compiled package:

import o6
import plant

server = o6.Server(port=4840)
server.ns.append(plant)
server.start()

o6.ns.plant works from anywhere in the process afterwards, and ns=plant;i=… resolves in every NodeId string. The shortname, uri and version you passed are what a client sees in the server's NamespaceArray.

Tip

For a single-file script, __main__ is a module too — server.ns.append(sys.modules[__name__]) publishes types declared in the script itself. Splitting the namespace into its own importable module is still preferable: it keeps declarations out of the if __name__ == "__main__" path and makes the module reusable by both a server and a client process.

Declaration order is dependency order

A decorator runs when Python executes the class statement, and it resolves every reference it is given at that moment. A type must therefore be declared before anything that names it:

@o6.datatype(ns="plant")
class Point:
    x: float


@o6.datatype(ns="plant")
class BoundingBox:
    min: Point          # Point already exists — fine
    max: Point

The reverse order fails with an explicit error rather than a half-built type:

TypeError: o6.datatype: cannot infer UA DataType for annotation ...
You have to declare types in dependency order: Type A must be declared
before Type B, if B has a field of type A.

Self-reference is the one exception — a type may name itself, because its NodeId is allocated before its fields are collected:

@o6.datatype(ns="plant")
class Recursive:
    name: str
    children: list["Recursive"]

NodeIds

Every decorator takes an optional nodeId=. Omit it and o6 allocates the next free numeric identifier in that namespace; pass it and the type pins that identifier forever.

@o6.objecttype(ns="plant", nodeId="ns=plant;i=1001", browseName="MachineType")
class MachineType(ns0.objtypes.BaseObjectType):
    ...

Auto-allocation is fine while a model is private and always loaded from the same source. Pin NodeIds explicitly as soon as the model is published: clients that hard-code identifiers, historical data, and stored configuration all depend on them, and an auto-allocated identifier moves whenever you reorder declarations.

browseName= defaults to the Python class name. Give it explicitly when the UA BrowseName differs from the identifier you want in Python, and qualify it ("ns=plant;MachineType") when it must live in a namespace other than the declaring one.


@o6.datatype — structures

A @o6.datatype class is a wire layout. Each annotated attribute becomes a field of the type's StructureDefinition, and the decorator registers the layout with open62541 so values of this shape encode and decode as a real structure rather than an opaque ExtensionObject.

@o6.datatype(ns="plant", description="3-D vector of doubles")
class Point:
    x: float
    y: float
    z: float

    def __init__(self, x: float = 0.0, y: float = 0.0, z: float = 0.0) -> None:
        self.x, self.y, self.z = x, y, z

    def __repr__(self) -> str:
        return f"Point(x={self.x}, y={self.y}, z={self.z})"

The __init__ and __repr__ are optional. Without them the type is still fully usable — o6 supplies a native initializer and a field-listing repr:

p = Point()
p.x = 1.0
p                       # {x=1.0, y=0.0, z=0.0}

Annotations map to UA DataTypes directly. Python builtins resolve to their OPC UA counterparts, and the sized o6 aliases are available whenever the exact width matters:

Annotation UA DataType
bool, int, float, str, bytes Boolean, Int64, Double, String, ByteString
o6.Int16, o6.UInt32, o6.Float, o6.Byte, … the exact built-in type
another @o6.datatype / @o6.enumtype class that type
o6.NodeId, o6.LocalizedText, o6.QualifiedName, … the built-in address/identity types
list[T] T with ValueRank = 1 (a 1-D array)
typing.Any BaseDataType (Variant)

Field metadata with o6.field

o6.field(...) attaches OPC UA metadata to an annotated field. The annotation still supplies the type; the factory only adds what the annotation cannot express:

@o6.datatype(ns="plant", description="One production batch")
class BatchRecord:
    batchId: str
    samples: list[float]
    comment: Optional[str] = o6.field(description="free-text operator note")
    tag: str = o6.field(maxStringLength=32)
  • description= — the field's Description in the StructureDefinition.
  • isOptional=True — an optional field. Optional[T] in the annotation does the same thing, and is the form to prefer.
  • valueRank= / arrayDimensions= — override the rank inferred from the annotation. Struct fields are scalars (-1) or 1-D arrays (1); anything else is rejected, because open62541 cannot represent a multi-dimensional array as a struct member.
  • maxStringLength= — a String/ByteString length hint.
  • name= — the UA field name. This renames the Python attribute along with the wire field, so it is mainly useful to the nodeset compiler when a UA field name is not a valid Python identifier.

As soon as one field is optional the type's StructureType becomes StructureWithOptionalFields; an unset optional field reads back as None.

record = BatchRecord()
record.batchId = "B-1042"
record.samples = [21.5, 21.7]
record                                  # {batchId='B-1042', samples=[21.5, 21.7], comment=None}

Inheritance and abstract structures

Python inheritance is the HasSubtype chain, and a subtype inherits its parent's fields:

@o6.datatype(ns="plant", isAbstract=True)
class AbstractResult:
    ok: bool


@o6.datatype(ns="plant")
class WeighResult(AbstractResult):
    mass: float


result = WeighResult()          # {ok=False, mass=0.0} — both fields
AbstractResult()                # TypeError: Cannot instantiate abstract data type

An abstract structure still carries a complete DataTypeDefinition, so it describes the shared layout for browsing clients while remaining non-instantiable. A field annotated with an abstract structure type is encoded as an ExtensionObject, which is what lets it carry any concrete subtype.

Unions

Derive from ns0.datatypes.Union and the type's StructureType becomes Union. Assigning a field selects it; the previously selected field is cleared:

@o6.datatype(ns="plant", description="Either a mass or a piece count")
class Measurement(ns0.datatypes.Union):
    mass: float
    count: o6.Int32


m = Measurement()
m.mass = 12.5
m                       # {mass=12.5}

Warning

Select a field before a union value crosses an encoder. A union with no field set has no valid wire representation, and writing one into the address space is not currently rejected cleanly.

Properties on a DataType node

A DataType node can own Properties. They are constructed first and linked in the class body like any other type child:

_enumStrings = ns0.vartypes.PropertyType(
    browseName="EnumStrings",
    value=[o6.LocalizedText("OFF"), o6.LocalizedText("ON")],
    dataType=o6.LocalizedText,
    valueRank=1,
)


@o6.enumtype(ns="plant", browseName="Mode")
class Mode(ns0.datatypes.Enumeration):
    enumStrings: ns0.vartypes.PropertyType = o6.hasProperty(_enumStrings)
    OFF = 0
    ON = 1

o6.hasProperty also keeps the linked node out of Python's enum member table, so Mode has exactly the two members you declared.


@o6.enumtype — enumerations

An @o6.enumtype class is a real IntEnum after decoration. Bare integer class attributes are enough:

@o6.enumtype(ns="plant", description="Top-level machine state")
class MachineState:
    IDLE = 0
    RUNNING = o6.enumfield(1, description="executing a program")
    HOLD = o6.enumfield(2, description="paused by operator", displayName="HOLD")
    FAULT = 3

o6.enumfield(value, ...) adds per-member UA metadata — description=, displayName=, and name= for a UA member name that is not a valid Python identifier. Members without it are plain values; the two forms mix freely in one class. Duplicate numeric values are rejected, because they are ambiguous on the wire.

Use the type as an annotation to give a struct field or a Variable that enum's DataType, and as a value wherever an integer is expected:

int(MachineState.RUNNING)               # 1
MachineState(1)                         # <MachineState.RUNNING: 1>

Abstract enum parents

An isAbstract=True enum has no members and no wire representation — it is a pure type-system placeholder that concrete enums can share:

@o6.enumtype(ns="plant", isAbstract=True, browseName="SpeedLimit")
class SpeedLimit:
    pass


@o6.enumtype(ns="plant", browseName="ConveyorSpeed")
class ConveyorSpeed(SpeedLimit):
    SLOW = 0
    FAST = 1


isinstance(ConveyorSpeed.FAST, SpeedLimit)      # True

This is the enum counterpart of an abstract structure: a Variable typed with the abstract parent accepts any of its concrete subtypes.


@o6.referencetype — custom references

ReferenceTypes are address-space metadata only: no UA_DataType, no encoding, nothing to instantiate. The marker class carries the NodeId, BrowseName, InverseName, Symmetric and IsAbstract that the server publishes, and Python inheritance is the HasSubtype chain.

@o6.referencetype(
    ns="plant",
    browseName="Feeds",
    inverseName="IsFedBy",
    description="Conveyor Feeds Machine",
)
class Feeds:
    pass

A ReferenceType marker must have no annotated fields, and it is never instantiable. Use it as the reference type argument wherever one is expected:

o6.reference(conveyor, Feeds, machine)          # between two live nodes
server.addReference(conveyor, machine, Feeds)   # equivalently

@o6.variabletype — typed Variables

A VariableType constrains a Variable's value and declares the children every Variable of that type gets. The value constraints are the decorator's own arguments:

@o6.variabletype(
    ns="plant",
    dataType=o6.Double,
    valueRank=o6.ValueRank.SCALAR,
    description="A temperature in degrees Celsius",
)
class TemperatureType(ns0.vartypes.BaseDataVariableType):
    engineeringUnits: ns0.vartypes.PropertyType = o6.hasProperty(
        ns0.vartypes.PropertyType(dataType=str, description="unit symbol")
    )
    highLimit: Optional[ns0.vartypes.PropertyType] = o6.hasProperty(
        ns0.vartypes.PropertyType(dataType=float)
    )
  • dataType=, valueRank=, arrayDimensions= describe the value. Omitted, they are inherited from the base VariableType, not reset to BaseDataType/ANY — OPC UA requires a subtype's constraints to be equal or narrower than its parent's, so inheriting is the only safe default.
  • value= seeds a default value on the type node itself.
  • isAbstract=True makes the type non-instantiable.
  • interfaces=[...] adds HasInterface references (see Server).

Almost every VariableType derives from ns0.vartypes.BaseDataVariableType. ns0.vartypes.PropertyType is the right base — and the right child type — for metadata that describes another node rather than carrying process data.

Children

Children are declared by annotating a class attribute with the child's concrete type and assigning a linked instance of that type:

@o6.variabletype(ns="plant", description="Inlet/outlet temperature pair")
class ThermalProfileType(ns0.vartypes.BaseDataVariableType):
    inlet: TemperatureType = o6.hasComponent(TemperatureType())
    outlet: TemperatureType = o6.hasComponent(TemperatureType())

Two things are happening in one line, and it is worth separating them:

  • The instance (TemperatureType()) describes the child node — its BrowseName, DataType, AccessLevel, default value. Constructed inside a namespace module with no live server, it stays an ordinary declaration.
  • The linker (o6.hasComponent / o6.hasProperty) states the reference type that attaches it to the parent. Each takes exactly one instance and returns it unchanged, so the static type survives and profile.inlet is a TemperatureType to the type checker.

Beyond the two common ones, o6.organizes, o6.hasEventSource, o6.hasNotifier, o6.hasOrderedComponent, o6.hasAddIn, o6.hasInterface, o6.hasCondition and o6.generatesEvent cover the remaining standard hierarchies; their inverse forms (o6.componentOf, o6.propertyOf, o6.organizedBy, …) point the reference the other way; and o6.reference(instance, SomeReferenceType) links through any custom ReferenceType.

Optionality belongs to the relationship and is inferred exclusively from Optional[T] in the annotation — an Optional[T] child gets the Optional ModellingRule, everything else is Mandatory. Node constructors deliberately have no optional argument. A relationship can even be declared without an instance yet:

documentation: Optional[ns0.vartypes.PropertyType] = o6.hasProperty(None)

@o6.objecttype — typed Objects

An ObjectType is the same story with Object children and Methods added. Everything about children, linkers and optionality carries over unchanged.

@o6.objecttype(ns="plant", description="A servo drive")
class DriveType(ns0.objtypes.BaseObjectType):
    manufacturer: ns0.vartypes.PropertyType = o6.hasProperty(
        ns0.vartypes.PropertyType(dataType=str)
    )
    current: ns0.vartypes.BaseDataVariableType = o6.hasComponent(
        ns0.vartypes.BaseDataVariableType(dataType=float, description="motor current [A]")
    )


@o6.objecttype(ns="plant", description="A machine with a drive and a temperature")
class MachineType(ns0.objtypes.BaseObjectType):
    state: ns0.vartypes.PropertyType = o6.hasProperty(
        ns0.vartypes.PropertyType(dataType=MachineState)
    )
    temperature: TemperatureType = o6.hasComponent(TemperatureType())
    drive: DriveType = o6.hasComponent(DriveType())
    reset: o6.node.MethodNode = o6.hasComponent(
        o6.call(
            browseName="ns=plant;Reset",
            inputArgs=[
                ns0.datatypes.Argument(
                    name="mode", dataType=o6.Int32, valueRank=o6.ValueRank.SCALAR
                )
            ],
            outputArgs=[
                ns0.datatypes.Argument(
                    name="ok", dataType=o6.Boolean, valueRank=o6.ValueRank.SCALAR
                )
            ],
        )
    )

state, temperature and drive show the three kinds of child: a leaf Property, a complex Variable, and a complex Object. reset is a Method declarationo6.call(...) with inputArgs/outputArgs describes the signature and nothing else. The Python behavior behind it is supplied separately; that is the whole subject of Implementing Object Behavior.

Subtyping is plain Python inheritance, and a subtype inherits every child of its base:

@o6.objecttype(ns="plant", description="A CNC machine")
class CncMachineType(MachineType):
    serial: ns0.vartypes.PropertyType = o6.hasProperty(ns0.vartypes.PropertyType(dataType=str))

CncMachineType has state, temperature, drive, Reset and serial.

An ObjectType may also declare an __init__. It runs on the fully constructed node, so super().__init__(**kwargs) is only the ordinary cooperative Python call and performs no OPC UA work:

@o6.objecttype(ns="plant")
class PumpType(ns0.objtypes.BaseObjectType):
    def __init__(self, *, device=None, **kwargs):
        super().__init__(**kwargs)
        self.device = device

Using the types

Once the module is appended, the declared classes are the API. Calling one either creates a live server node or returns a declaration, depending on which server owns the construction — the full resolution order is in Server.

Instantiating

import o6
import plant

server = o6.Server(port=4840)
server.ns.append(plant)

machine = plant.CncMachineType(
    parent=server.objectsNode,
    browseName="M-101",
    nodeId="ns=plant;i=5001",
    values={
        "state": int(plant.MachineState.RUNNING),
        "serial": "SN-001",
        "drive": {"manufacturer": "ACME", "current": 0.0},
    },
)

values= seeds the instance's children by Python member name:

  • A leaf child takes a plain Python value.
  • A complex child — one whose own type declares children — takes a dict of its children, applied recursively. Passing a scalar there is an error (complex child 'drive' needs a DriveType declaration or a dict of values).
  • To seed a complex child's own value as well as its children, pass a detached declaration instead of a dict:

    values={
        "temperature": plant.TemperatureType(
            server=None,                       # force a declaration
            value=19.0,                        # the Variable's own value
            values={"engineeringUnits": "degC"},
        ),
    }
    

Children not named in values= are still created — every Mandatory child of the type exists on every instance, with a zero-initialized value.

Reading and writing

Children are reached by dot access, and a Variable node is callable: no argument reads, one argument writes.

machine.serial()                        # 'SN-001'
machine.drive.manufacturer()            # 'ACME'
machine.drive.current(1.25)             # write
machine.temperature.engineeringUnits()  # 'degC'

o6.NodeId(machine.drive.current)        # ns=… — the NodeId of any node handle

A struct-valued Variable round-trips as the Python class, not as an ExtensionObject:

origin = server.addVariable("Origin", server.objectsNode, plant.Point(1.0, 2.0, 3.0))
server.read(origin)                     # {x=1.0, y=2.0, z=3.0}

From a client

A client appends nothing. On connect it maps the server's NamespaceArray onto the o6.ns table, so as long as the same plant module is importable in the client process, its types decode and ns=plant;… NodeIds resolve:

import plant                            # registers the namespace in this process too

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

client.read("ns=plant;i=5001")
client.call(o6.NodeId(machine), o6.NodeId(machine.Reset), [o6.Int32(1)])

A client that never imports the module still talks to the server, but sees numeric namespace indices and decodes plant structures as opaque ExtensionObjects. See Using Nodesets for why merely importing is sometimes not enough for a compiled namespace.


See also