Declared types¶
The declarative route describes the model once, as Python classes, and lets the
server build the nodes. A namespace module is an ordinary Python module that
calls o6.ns.namespace(...) at the top and defines decorated classes below it:
# 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
@o6.variabletype(ns="plant", dataType=o6.Double, valueRank=o6.ValueRank.SCALAR)
class TemperatureType(ns0.vartypes.BaseDataVariableType):
engineeringUnits: ns0.vartypes.PropertyType = o6.hasProperty(
ns0.vartypes.PropertyType(dataType=str)
)
highLimit: Optional[ns0.vartypes.PropertyType] = o6.hasProperty(
ns0.vartypes.PropertyType(dataType=float)
)
@o6.objecttype(ns="plant", nodeId="ns=plant;i=1001", browseName="MachineType")
class MachineType(ns0.objtypes.BaseObjectType):
state: ns0.vartypes.PropertyType = o6.hasProperty(
ns0.vartypes.PropertyType(dataType=MachineState)
)
temperature: TemperatureType = o6.hasComponent(TemperatureType())
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)],
)
)
The server publishes it, and from then on the classes are the API:
import o6
import plant
server = o6.Server(port=4840)
server.ns.append(plant)
server.start()
machine = plant.MachineType(
parent=server.objectsNode,
browseName="M-101",
nodeId="ns=plant;i=5001",
values={"state": int(plant.MachineState.RUNNING)},
)
machine.state() # 1
machine.temperature(23.5)
machine.temperature.engineeringUnits()
server.ns.append(module) registers every namespace the module declared (from
its __NAMESPACES__ metadata) and then injects its decorated nodes. Appending
the same module twice is a no-op. A module that mixes already-published
namespace URIs with new ones is rejected, and when a URI is already published
only the index alias is added — one address space holds one release of a given
namespace URI. For a single-file script, server.ns.append(sys.modules[__name__])
works, though a separate importable module is preferable because a client
process can import the same module and decode the types.
The decorators themselves — @o6.datatype, @o6.enumtype,
@o6.referencetype, @o6.variabletype, @o6.objecttype, field metadata,
declaration order, NodeId allocation, and values= seeding — are the subject of
Writing a Nodeset in Python.
Packaged companion specifications (DI, IA, and friends) are appended the same
way and are covered in
Using Nodesets. The
rest of this chapter covers the parts that belong to the server rather than to
the model.
Declared type instance ownership¶
Calling an @o6.objecttype or @o6.variabletype class creates either a live
server node or an ordinary declaration instance. The optional server argument
is resolved in this order:
- An explicit
server=<Server>selects that server. An explicitserver=Noneforces a declaration instance. - A live parent node selects the server that owns that node. A conflicting explicit server is rejected.
- A declaration parent produces another declaration.
- Calls made while a registered Python namespace module is being evaluated
remain declarations;
server.ns.append(module)materializes them later. - Otherwise, exactly one live server in the Python process is inferred.
- With no live server, the result is a declaration. With multiple live servers,
construction is ambiguous and raises
TypeError; passserver=explicitly.
A bare NodeId does not identify a server. It therefore relies on the unique
live-server rule — and server.objectsNode is a NodeId, not a node handle:
import o6
server = o6.Server()
# The only live server is inferred even though objectsNode is a NodeId.
motor = MotorType(parent=server.objectsNode, browseName="Motor")
# Always a normal declaration instance, regardless of live servers.
declaration = MotorType(server=None)
With a second live server in the process, the same call fails with
cannot infer server: multiple live servers exist; pass server=<server> or
server=None explicitly. Pass server=server and it succeeds again.
Namespace declarations remain ordinary Python objects even if a server exists:
import sys
import o6
from o6.ns import ns0
o6.ns.namespace(shortname="plant", uri="urn:example:plant")
motors = ns0.objtypes.FolderType(parent=ns0.instances.objects, browseName="Motors")
server.ns.append(sys.modules[__name__])
Views¶
Views are address-space instances, so they are declared with the o6.view
factory rather than a class decorator:
productionView = o6.view(
nodeId="ns=plant;i=5001",
browseName="Production",
containsNoLoops=True,
)
The default parent is the standard Views folder and the default reference is
Organizes. Server selection follows the same rules as other declared
instances: pass server= explicitly, let a live parent or the sole live server
select it, or use server=None to retain the declaration for a later
server.ns.append(module) call. references=[...] attaches existing
declarations to the View, and the remaining arguments (displayName,
description, eventNotifier, writeMask, rolePermissions,
accessRestrictions) map onto the corresponding attributes.
For a View created at runtime rather than declared in a model, use
server.addView.
Type child relationships¶
Children of an @o6.variabletype or @o6.objecttype are defined as node
instances first, then linked with one of two camel-case identity helpers:
_name = ns0.vartypes.PropertyType(dataType=o6.String, browseName="Name")
_controller = ControllerType()
_reset = o6.call()
@o6.objecttype(nodeId="ns=plant;i=1001", browseName="MachineType")
class MachineType(ns0.objtypes.BaseObjectType):
name: ns0.vartypes.PropertyType = o6.hasProperty(_name)
controller: ControllerType = o6.hasComponent(_controller)
reset: o6.node.MethodNode = o6.hasComponent(_reset)
hasProperty and hasComponent each take exactly one existing instance and
preserve its static type: an input of type T returns T. They work for
Variables, Objects, Methods, and their subtypes.
Beyond those two, o6.organizes, o6.hasEventSource, o6.hasNotifier,
o6.hasOrderedComponent, o6.hasAddIn, o6.hasInterface, o6.hasCondition,
o6.generatesEvent, and o6.hasEncoding cover the remaining standard
hierarchies. Each has an inverse form — o6.propertyOf, o6.componentOf,
o6.organizedBy, o6.eventSourceOf, o6.notifierOf,
o6.orderedComponentOf, o6.addInOf, o6.interfaceOf, o6.isConditionOf,
o6.generatedBy — that points the reference the other way. For a custom
ReferenceType, o6.reference(instance, SomeReferenceType) links through it, and
the three-argument form o6.reference(source, SomeReferenceType, target) adds
an edge between two live nodes immediately.
Optionality belongs to the relationship and is inferred exclusively from
Optional[T]:
_componentName = ns0.vartypes.PropertyType(dataType=o6.LocalizedText)
componentName: Optional[ns0.vartypes.PropertyType] = o6.hasProperty(_componentName)
# The instance can also be left undefined.
documentation: Optional[ns0.vartypes.PropertyType] = o6.hasProperty(None)
Node constructors describe the node itself. They do not accept an optional
argument.
Type interfaces¶
An ObjectType or VariableType implements OPC UA InterfaceTypes through the
decorator's interfaces argument:
@o6.objecttype(
nodeId="ns=plant;i=1001",
browseName="MachineType",
interfaces=[IMaintainableType, ns0.objtypes.IOrderedObjectType],
)
class MachineType(ns0.objtypes.BaseObjectType):
pass
Interface markers are abstract ObjectTypes derived from
ns0.objtypes.BaseInterfaceType. Interfaces are OPC UA type metadata, not Python
mixins: they do not enter the implementing class's MRO and their members are
not copied into its class body. The server adds the HasInterface references
after all types in the module exist. Mandatory interface members are then
instantiated according to the normal OPC UA type-instantiation rules.
Properties owned directly by a DataType or EnumType are constructed before the
type and passed through the decorator's children argument:
_enumStrings = ns0.vartypes.PropertyType(
browseName="EnumStrings",
value=[o6.LocalizedText("OFF"), o6.LocalizedText("ON")],
dataType=o6.LocalizedText,
valueRank=1,
)
@o6.enumtype(ns="plant", nodeId="ns=plant;i=1200", browseName="Mode")
class Mode(ns0.datatypes.Enumeration):
enumStrings: ns0.vartypes.PropertyType = o6.hasProperty(_enumStrings)
OFF = 0
ON = 1
The relationship is declared on the type class, like other type children.
o6.hasProperty also keeps the linked node out of Python's enum member table.
These children are injected beneath the DataType node; they are not emitted as
free-standing instances with an explicit parent NodeId.