Implementing OPC UA Companion Specs: Hands-On Tutorial (2026)

Implementing OPC UA Companion Specs: Hands-On Tutorial (2026)

Implementing OPC UA Companion Specs: Hands-On Tutorial (2026)

Most OPC UA tutorials stop at adding a MyTemperature variable to the address space and call that interoperability. It is not. Real interoperability happens when two different vendors expose pumps, robots, or CNCs through the same typed nodes, the same methods, and the same identification properties — and that only happens when each vendor implements an OPC UA companion specification. This hands-on guide walks through a full OPC UA companion specification implementation on open62541, loads the Machinery and Pumps NodeSets, exposes a conformant pump instance, and consumes it from a Python asyncua client. We use only documented, public APIs and flag every place where the exact call name depends on the open62541 release you have checked out, so you can adapt the steps to the version on your bench instead of copying calls that may have been renamed.

What companion specs actually solve

A companion specification is a contract: it tells every vendor of a given asset class which nodes, references, and methods their OPC UA server must expose, and which NamespaceUri to use. Without one, two pump servers are unreadable by the same client without per-vendor mapping code.

The core problem an OPC UA companion specification implementation solves is the N x M mapping explosion. Five MES vendors plus fifty pump vendors equals 250 ad-hoc integrations. With a companion spec, the MES learns the spec once and reads any conformant pump.

Concretely a companion spec defines:

  • A reserved NamespaceUri (for example http://opcfoundation.org/UA/Pumps/).
  • A set of ObjectTypes and VariableTypes with required and optional children.
  • Reference types that wire those objects together.
  • Methods (e.g. Reset, Acknowledge) with input and output arguments.
  • Identification properties that travel with every instance (manufacturer, model, serial, asset id).
  • A canonical NodeSet2.xml distribution that machine-generates the address space.

That last point is what makes the spec executable. The NodeSet2.xml is the source of truth; servers load it, clients parse it, and conformance testers compare against it. You do not write the type tree by hand — you load the XML and instantiate the types.

Layered architecture: base OPC UA, DI, Machinery and industry-specific companion specs

The 2026 companion-spec landscape

There are now well over sixty published companion specs across discrete manufacturing, process, energy and building automation. They cluster into three useful layers: a base device-integration layer, a cross-industry machinery layer, and industry-specific layers built on top of those.

In practice, you almost always touch three of them together. Even a “simple” pump uses DI for the identification skeleton, Machinery for the machine-grouping pattern, and the Pumps spec for the actual pump semantics. That layered reuse is the whole point: each spec extends the layer below and adds only what is unique to its scope.

The most-used 2026 companion specs are:

Spec What it covers Why it matters
OPC 10000-100 (DI) Devices, components, identification, configuration Universal foundation — every device-class spec extends DI
OPC 40001 Machinery Machines folder, MachineIdentification, MachineComponents Cross-industry building blocks reused by everything mechanical
OPC 40083 Pumps & Vacuum Pumps, pump systems, pump units Process industries, water, oil and gas
OPC 40010 Robotics Motion devices, axes, safety states Standardised robot telemetry across vendors
OPC 40501 Machine Tools (umati) CNC machines, channels, tools umati ecosystem; very high adoption in machine-tool builders
OPC 40050 PackML Packaging machines, PackML state model Discrete packaging lines
Weihenstephan Standards Food and beverage packaging KPIs Brewing, beverages, dairy lines
OPC 40100 Machine Vision Cameras, recipes, results Inspection systems

For the rest of this OPC UA companion specification implementation tutorial we focus on the DI + Machinery + Pumps stack, because it exercises every interesting construct: identification, components, methods, and live values. The pattern transfers directly to Robotics or PackML.

Tutorial environment setup

You will need a Linux box (Ubuntu 24.04 or WSL2 on Windows), about 1 GB of build space, network reachability to github.com and reading.opcfoundation.org, and around 30 minutes for the first compile.

The 60-second checklist:

# 1. Toolchain
sudo apt update
sudo apt install -y build-essential cmake git python3-pip python3-venv libssl-dev

# 2. open62541 — pin to a known-good tag
git clone https://github.com/open62541/open62541.git
cd open62541
git checkout v1.4.10            # any current v1.4.x is fine
git submodule update --init --recursive

# 3. Python venv for the client
cd ..
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install asyncua==1.1.0       # client and server
pip install opcua-tools           # NodeSet utilities; optional

Download the NodeSet2.xml files. The OPC Foundation publishes them in a public read-only repository and on the reading site; the canonical mirror is the UA-Nodeset GitHub repo. We will reference three:

mkdir -p nodesets && cd nodesets
BASE=https://raw.githubusercontent.com/OPCFoundation/UA-Nodeset/v1.05.04
curl -O ${BASE}/DI/Opc.Ua.Di.NodeSet2.xml
curl -O ${BASE}/Machinery/Opc.Ua.Machinery.NodeSet2.xml
curl -O ${BASE}/Pumps/Opc.Ua.Pumps.NodeSet2.xml
cd ..

If you cannot reach GitHub from the build host, the same files are published at reading.opcfoundation.org under each spec’s page; download them once and copy them in.

Verify with head -2 *.xml that each file starts with <?xml version="1.0" and a <UANodeSet ...> root. The Pumps file declares a dependency on Machinery, which in turn declares a dependency on DI — load order matters and the build will reject reversed order.

Tutorial environment: dev host, open62541 server, asyncua client and UaExpert

From XML to runnable types

open62541 uses a Python tool called nodeset-compiler (in tools/nodeset_compiler/) that consumes one or more NodeSet2.xml files and emits a generated .c and .h pair. Your server main() includes the generated header and calls the generated bootstrap function during startup; the calls expand into hundreds of UA_Server_addNode_* invocations that recreate the type tree exactly as the spec defines it.

The typical pattern is:

# from the open62541 checkout root
python3 tools/nodeset_compiler/nodeset_compiler.py \
  --types-array=UA_TYPES \
  --existing  deps/ua-nodeset/Schema/Opc.Ua.NodeSet2.xml \
  --xml       ../nodesets/Opc.Ua.Di.NodeSet2.xml          ns_di \
  --xml       ../nodesets/Opc.Ua.Machinery.NodeSet2.xml   ns_machinery \
  --xml       ../nodesets/Opc.Ua.Pumps.NodeSet2.xml       ns_pumps \
  ../build/generated/companion_nodesets

This produces companion_nodesets.c and companion_nodesets.h. The exact flag names — --types-array, --existing, --xml — have been stable across recent open62541 releases, but if you are on a much newer or older tag, run the compiler with --help and translate accordingly rather than trusting any single example online.

If you prefer to skip the C codegen and stay in Python, asyncua can load NodeSet2.xml files at runtime via server.import_xml(). That path is dramatically slower to start (loads happen during boot) and uses more RAM, but it is fine for development and for low-throughput production servers.

NodeSet2.xml to generated C and Python types pipeline

Step-by-step server implementation (open62541, C)

The minimum viable server is roughly 80 lines of C: include open62541, create a server config, load the companion namespaces, instantiate one pump, and run forever. Build and link against the static library produced by the CMake build of open62541.

The skeleton uses only documented open62541 API surface. Where exact symbol names depend on your open62541 version, the typical pattern is shown and you should consult <open62541/server.h> in your checkout.

/* pump_server.c — minimal companion-spec server */
#include <open62541/server.h>
#include <open62541/server_config_default.h>
#include <open62541/plugin/log_stdout.h>
#include <signal.h>
#include <stdlib.h>

#include "companion_nodesets.h"   /* generated by nodeset_compiler */

static volatile UA_Boolean running = true;
static void stopHandler(int sig) { (void)sig; running = false; }

int main(void) {
    signal(SIGINT, stopHandler);

    UA_Server *server = UA_Server_new();
    UA_ServerConfig_setDefault(UA_Server_getConfig(server));

    /* Load DI -> Machinery -> Pumps in order.
       The generated functions are named after the --xml labels you
       passed to nodeset_compiler (ns_di, ns_machinery, ns_pumps). */
    if(ns_di(server)        != UA_STATUSCODE_GOOD) goto fail;
    if(ns_machinery(server) != UA_STATUSCODE_GOOD) goto fail;
    if(ns_pumps(server)     != UA_STATUSCODE_GOOD) goto fail;

    /* Now the address space contains the Pump ObjectType. Instantiate
       one pump under Objects/Machines/. The exact NodeIds below come
       from each spec's NodeSet2.xml — look them up in your downloaded
       files, do not hand-edit. */

    /* Find the Machines folder (Machinery spec, well-known NodeId). */
    UA_NodeId machinesFolder = UA_NODEID_NUMERIC(/*nsIdx*/2, /*i*/1001);

    /* PumpType from the Pumps spec. */
    UA_NodeId pumpType = UA_NODEID_NUMERIC(/*nsIdx*/4, /*i*/1002);

    UA_ObjectAttributes pumpAttr = UA_ObjectAttributes_default;
    pumpAttr.displayName = UA_LOCALIZEDTEXT("en", "Pump_001");
    pumpAttr.description = UA_LOCALIZEDTEXT("en", "Centrifugal pump CP-101");

    UA_NodeId pumpInstanceId;
    UA_Server_addObjectNode(server,
        UA_NODEID_NULL,                                  /* let server assign id */
        machinesFolder,
        UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES),
        UA_QUALIFIEDNAME(4, "Pump_001"),
        pumpType,
        pumpAttr, NULL, &pumpInstanceId);

    UA_StatusCode rc = UA_Server_runUntilInterrupt(server);

fail:
    UA_Server_delete(server);
    return (int)rc;
}

A few notes on what is intentionally elided. The exact numeric NodeIds for MachinesFolder and PumpType must come from the NodeSet2.xml files — open them in any text editor and search for BrowseName="Machines" and BrowseName="PumpType". Hard-coding them as I have done is fine for a tutorial; in production code, prefer UA_Server_browseRecursive or the helper UA_NODEID_STRING lookups so you do not silently break when the spec versions up.

The UA_Server_addObjectNode call with the parent class pumpType triggers what open62541 calls instantiation: the server walks the type’s modelling rules (Mandatory, Optional, MandatoryPlaceholder) and adds all Mandatory children automatically. After this call your Pump_001 already carries the full MachineIdentification (manufacturer, model, serial), the Status variable, and the methods declared in the Pumps spec — without you adding them by hand.

Populating identification and live values

Companion specs are useful only if you fill in the identification fields and wire live values to the runtime. Pseudocode for the identification block:

/* Set Manufacturer property on the new pump's MachineIdentification. */
UA_NodeId identNode;        /* find by BrowseName under pumpInstanceId */
UA_NodeId manufacturer;     /* find by BrowseName "Manufacturer" under identNode */

UA_LocalizedText mfg = UA_LOCALIZEDTEXT("en", "ACME Pumps GmbH");
UA_Variant v; UA_Variant_setScalar(&v, &mfg, &UA_TYPES[UA_TYPES_LOCALIZEDTEXT]);
UA_Server_writeValue(server, manufacturer, v);

For dynamic values such as flow rate or pressure, attach a value callback so reads always return live data instead of a stored snapshot:

static UA_StatusCode readFlowRate(UA_Server *s, const UA_NodeId *sessionId,
                                  void *sessionContext, const UA_NodeId *nodeId,
                                  void *nodeContext, UA_Boolean sourceTimeStamp,
                                  const UA_NumericRange *range, UA_DataValue *dv) {
    UA_Double flow = read_sensor_flow();          /* your driver */
    UA_Variant_setScalarCopy(&dv->value, &flow, &UA_TYPES[UA_TYPES_DOUBLE]);
    dv->hasValue = true;
    return UA_STATUSCODE_GOOD;
}

UA_ValueCallback cb = { .onRead = readFlowRate, .onWrite = NULL };
UA_Server_setVariableNode_valueCallback(server, flowRateNode, cb);

That is the entire integration story on the C side: load NodeSets, instantiate the type, set identification once at startup, attach callbacks for live values.

Step-by-step client implementation (Python, asyncua)

The client side is where companion specs pay back the most. Once a vendor exposes a conformant pump, the same client talks to any other conformant pump without code changes. Below is a minimal asyncua client that browses a pump, reads its identification, subscribes to flow rate, and invokes the Reset method.

# pump_client.py
import asyncio
from asyncua import Client, ua

URL = "opc.tcp://localhost:4840"
PUMPS_NS_URI = "http://opcfoundation.org/UA/Pumps/"

class FlowSub:
    """SubscriptionHandler implementing data_change_notification."""
    async def datachange_notification(self, node, val, data):
        print(f"flow change: {val}")

async def main():
    async with Client(url=URL) as client:
        # Resolve the Pumps namespace index dynamically — never hard-code.
        idx_pumps = await client.get_namespace_index(PUMPS_NS_URI)

        # Browse to the first pump instance under Objects/Machines.
        objects = client.nodes.objects
        machines = await objects.get_child(["2:Machines"])  # ns index for Machinery
        children = await machines.get_children()
        if not children:
            raise RuntimeError("no machines exposed by server")
        pump = children[0]

        # Read identification.
        ident = await pump.get_child(["3:Identification"])  # ns index for DI
        manufacturer = await (await ident.get_child(["3:Manufacturer"])).read_value()
        serial       = await (await ident.get_child(["3:SerialNumber"])).read_value()
        print(f"pump: {manufacturer} / {serial}")

        # Subscribe to FlowRate.
        flow = await pump.get_child([f"{idx_pumps}:FlowRate"])
        sub = await client.create_subscription(500, FlowSub())
        await sub.subscribe_data_change(flow)

        # Call Reset() — input args list may be empty.
        try:
            await pump.call_method(f"{idx_pumps}:Reset")
            print("reset invoked")
        except ua.UaStatusCodeError as e:
            print(f"reset failed: {e}")

        await asyncio.sleep(30)
        await sub.delete()

if __name__ == "__main__":
    asyncio.run(main())

Three patterns are worth calling out because they are the difference between fragile demo code and a production client:

  1. Resolve namespace indices at runtime via get_namespace_index(uri). The index a server uses for the Pumps namespace is not fixed — it depends on the order the server loaded its NodeSets. Hard-coding ns=4 is the single most common cause of “works on my box, broken in staging” client failures.
  2. Browse, do not hand-build NodeIds. Companion-spec instances always carry the spec’s BrowseName, so get_child(["3:Manufacturer"]) works whether the server is open62541, Siemens, Beckhoff, or Prosys.
  3. Treat method calls as fallible. Per-spec, some methods (Reset, Acknowledge) require certain states. Wrap in try/except UaStatusCodeError and surface the status code; do not swallow.

Client browse, read, write method, subscribe MonitoredItem sequence

Verifying with UaExpert

UaExpert (free from Unified Automation) is the standard visual debugger for OPC UA. Connect to opc.tcp://localhost:4840 with Anonymous and None/None security. The address-space tree on the left should show, under Objects, a Machines folder containing Pump_001, and under that the Identification block, Status variable, the methods declared in the Pumps spec, and any live variables you attached.

Three things to check in UaExpert before declaring conformance:

  • The NamespaceArray (under Server > NamespaceArray) lists http://opcfoundation.org/UA/DI/, .../Machinery/, and .../Pumps/. If any is missing your NodeSet load failed silently.
  • The pump’s TypeDefinition reference points back to PumpType in the Pumps namespace, not to BaseObjectType. Right-click the pump, choose TypeDefinition, follow the link.
  • The Identification sub-folder contains Manufacturer, Model, SerialNumber, ProductCode, DeviceRevision — at minimum the mandatory ones from DI. If any are missing the type instantiation skipped a step.

UaExpert’s Document → Data Access View lets you drag any variable onto a live table for quick subscription testing. For automated conformance, the OPC Foundation publishes the UA Compliance Test Tool (UACTT) and per-spec test packs; running UACTT against your server is the only way to get a defensible conformance claim.

A short script-based smoke test catches the same first-line issues without UaExpert in the loop. The pattern is to connect, ask for the NamespaceArray, and check it contains the expected URIs:

async with Client(URL) as c:
    arr = await c.nodes.namespace_array.read_value()
    required = [
        "http://opcfoundation.org/UA/DI/",
        "http://opcfoundation.org/UA/Machinery/",
        "http://opcfoundation.org/UA/Pumps/",
    ]
    missing = [u for u in required if u not in arr]
    assert not missing, f"server missing namespaces: {missing}"

Run that as the first stage of any CI pipeline that touches the server image. It is cheap and catches the classic failure mode where the server is up, anonymous binds work, but the address space is silently empty of companion types because a NodeSet load aborted halfway. Layer the UACTT only after this smoke test passes; UACTT runs take many minutes and you do not want to learn from a 15-minute test that you forgot to load Machinery.

Trade-offs and gotchas

Companion specs are powerful but they are not free. Three trade-offs come up in almost every project.

Bloat versus completeness. A conformant Pumps server inherits roughly 200 nodes per instance. On a constrained edge device, 50 pumps means 10,000 address-space nodes. open62541 handles that comfortably; a small PLC’s embedded OPC UA stack may not. Profile before you scale.

Optional fields rot fast. The spec marks many properties as Optional. Vendors picking different optional sets create a soft fragmentation that defeats the point of the spec. Decide which optional properties your organisation requires and enforce it in a conformance gate.

NodeSet versioning is real. The Pumps spec has shipped several versions; each NodeSet2.xml declares a PublicationDate. If a downstream client was tested against v1.00 and your server now ships v1.01, browse names may differ. Pin NodeSet versions in your build and bump deliberately.

Codegen drift. open62541’s nodeset_compiler flag set has changed across major versions. CI that pinned the wrong tag has silently produced empty NodeSets for some teams. Always grep the generated file for the expected ObjectType count and fail the build if it is below threshold.

Methods carry side effects. Methods like Reset or AcknowledgeAlarm are not read-only. Authorisation matters; the spec rarely tells you which users may invoke them. Wire your server’s accessControl plugin to gate method calls explicitly, not implicitly.

Practical recommendations

After a few rollouts, a stable checklist emerges. Use this as a starting point and adapt it to your asset class.

  • Pin your NodeSet2.xml files in version control. Treat them like any other source artifact. Diff between releases.
  • Generate, do not hand-code, the type tree. Either via open62541’s nodeset_compiler or asyncua.import_xml. Hand-built address spaces drift from spec within one sprint.
  • Use the deepest spec your asset matches. A pump should expose Pumps types, not just Machinery types. Clients that key on PumpType cannot find your pump if you stopped one layer short.
  • Document optional choices. Publish an internal “company profile” that says which Optional properties are mandatory inside your fleet. Use that as your real conformance contract.
  • Build a conformance smoke test. A 30-line client that connects, browses, and checks for required identification fields catches 80% of regressions for free. Run it in CI on every PR.
  • Aggregate with the same model upstream. Whether you use OPC UA aggregating servers, MQTT + JSON, or a Unified Namespace, keep the type names from the companion spec end-to-end. Renaming Manufacturer to vendor at the edge breaks every downstream consumer.
  • Plan a NodeSet upgrade cadence. Roll specs forward at the same cadence as firmware. Surprise upgrades break clients.

Production deployment: multiple conformant servers aggregated into a UNS

A useful related read on the wider OPC UA stack is our OPC UA FX field-level communications analysis, which covers how companion specs extend downward into the field layer.

Performance, footprint and observability notes

A conformant companion-spec server is not free. Plan capacity early. Memory, CPU and network usage all scale with the number of instances, the depth of optional children you choose to expose, and the rate at which clients subscribe to MonitoredItems.

Memory. As a rough rule of thumb on open62541 v1.4, a single fully-instantiated Pumps PumpType occupies on the order of 100 KB once all Mandatory children, references and value caches are accounted for. A server with 100 pumps and 20 mandatory variables per pump under active subscriptions easily exceeds 50 MB resident set size before TLS sessions are counted. Embedded targets should profile with valgrind --tool=massif on a representative load before locking in topology.

CPU on subscriptions. The dominant cost is sampling MonitoredItems, not browsing. A 100 ms sampling interval across thousands of items will saturate a single core well before the wire is the bottleneck. Use the deadband filter in the MonitoredItemFilter to suppress noise (DeadbandType=Absolute, DeadbandValue=0.01); deadband filtering is documented in OPC 10000-4 and supported by every mainstream stack.

Wire bandwidth. Companion-spec values are typed but compact. A DataChangeNotification carrying a single Double plus its ServerTimestamp is well under 100 bytes on the wire post-secure-channel framing. The cost driver is not the spec but the publishing interval and the number of items; a 50 ms interval with 5000 items will use far more bandwidth than the same items at 1 s. Match interval to the actual sensor update rate.

Observability inside the server. open62541 v1.4 exposes a logging hook; route it through your standard log pipeline so that NodeSet loads, subscription creation, and method invocations are auditable. For Python, attach a logging handler to asyncua at INFO level; the stack already emits one line per subscription event. In both stacks, treat every BadShelvedOptionAlarmSetState or BadNotImplemented line as a conformance bug, not a curiosity.

Health metrics. Expose three metrics out of every companion-spec server: number of loaded namespaces (should match the spec’s dependency list), number of active sessions, and number of monitored items. Plot them in your observability stack — sudden drops at the namespace count are nearly always a botched deployment.

FAQ

What is the difference between OPC UA, OPC UA companion specs, and OPC UA FX?
Plain OPC UA is the underlying protocol — secure channels, services, address space, PubSub. Companion specifications are information models layered on top of OPC UA that standardise how a specific asset class exposes its data. OPC UA FX (Field eXchange) is a separate, newer initiative extending OPC UA into deterministic field-level communications. A pump can simultaneously speak OPC UA, conform to the Pumps companion spec, and one day be reachable via OPC UA FX.

Do I need to pay the OPC Foundation to use a companion specification?
No. Reading and implementing companion specs is free for everyone; the specs are published on reading.opcfoundation.org. Logo certification, listing as an officially compliant product, and using OPC Foundation marketing marks requires Foundation membership and passing UACTT testing. Most production deployments implement the spec but never seek logo certification, which is fine technically though it reduces buyer trust.

Can I implement companion specs in Python only, without C?
Yes. asyncua loads NodeSet2.xml at runtime via server.import_xml() and exposes the resulting types. The trade-off is cold-start time and memory: a server loading DI, Machinery, and Pumps takes several seconds to import and uses more RAM than the compiled open62541 equivalent. For development, integration testing, and low-throughput servers, Python-only is the fastest path. For embedded targets or hundreds of monitored items, the open62541 C path wins.

How do I extend a companion spec for my proprietary data?
Use sub-types. Create a new ObjectType derived from PumpType in your own NamespaceUri and add your proprietary properties there. Never modify the official NodeSet2.xml — your build will fail downstream conformance and your additions will collide on the next spec revision. Clients that know your sub-type read your extensions; clients that only know base Pumps still see the conformant pump.

Does Sparkplug B replace OPC UA companion specifications?
No, they solve different problems. Sparkplug B is a payload standard for MQTT; it standardises message structure and birth/death semantics, but not the semantic model of a pump or robot. Companion specs are exactly that semantic model. Many production architectures use both: companion-spec OPC UA at the device, then Sparkplug B over MQTT for the long-haul transport. The companion-spec browse names survive the bridge.

How do I keep companion-spec data flowing in a Unified Namespace?
Map the OPC UA type names onto the Unified Namespace topic tree mechanically. A pump at Objects/Machines/Pump_001 becomes enterprise/site/area/line/Pump_001/... with one topic per relevant variable. Carry the Identification block as a single JSON birth message so consumers can resolve the asset class without browsing. The key invariant is preserve the names; do not rename FlowRate to flow_lpm along the way.

Further reading

If you walk away with one habit, make it this: never write a custom ObjectType for an asset class that already has a companion spec. Load the spec, instantiate the type, fill in the identification, and let your clients benefit from the years of cross-vendor negotiation that produced it.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *