basysKom Application Development Services

OPC UA: Programming against Type Descriptions
Essential Summary
OPC UA client code that relies on hardcoded NodeIds is brittle and often only works with a specific OPC UA server instance. This article shows the proper way to write robust and portable OPC UA client code.
Professional OPC UA Services

In need of an OPC UA interface, a custom information model, or simply consultation on OPC UA implementation?

We have received the requirement for an "OPC UA interface" - where do I start?

First, it is necessary to distinguish whether a server or a client is needed. An OPC UA server is the more common and also more complex case, as data is not consumed from an existing server but must be provided in a suitably defined model.

Addressing OPC UA Nodes

Information in OPC UA servers is stored in so-called “nodes”. Every node is identified by a NodeId which is used to address the node in OPC UA service calls (e.g. read/write/method call). So a client that wants to access the nodes of a server needs to find out about the NodeIds involved. While it might be tempting to launch a graphical browser like UaExpert to find the nodes you need and to copy their NodeIds into your application code, this is most often a bad idea.

This article explains why this is the case and shows the preferable approach, leading to more robust, portable client code.

Introduction to the OPC UA Address Space

The address space of an OPC UA server is a full mesh network of nodes connected by references.

Nodes are grouped into different NodeClasses (e.g. Object, ObjectType, Variable, …) and have a set of data elements named attributes that can be read and written. Some attributes like NodeId, BrowseName and DisplayName are present for all NodeClasses while others like Value or EventNotifier are NodeClass specific.

A server’s address space is divided into different namespaces that are identified by a URI and are listed in the Namespaces array node of the Server object. The array index of a namespace is used as part of the NodeId type in the NodeId attribute of every node to assign it to a namespace. The identifier part of the NodeId can be an integer, a string, a GUID or an opaque ByteString and must be unique in the context of its namespace.

References connect nodes to other nodes and have a semantic meaning expressed by their name. For example, an Object node of the type FolderType has Organizes references to the nodes it contains. An Object node modelling a complex real world object usually references its children using HasComponent. References may also connect nodes in different namespaces.

Why not to use hardcoded NodeIds

While there are some well-known NodeIds defined by the OPC UA standard or a companion specification like the Objects folder, the Server object or the Machines folder from the Machinery CS, most of the server specific nodes containing data are not guaranteed to always have the same NodeId. Some servers will use string NodeIds representing a path (e.g. MyMachine.Motor.RPM) or implement some mechanism to generate numeric NodeIds to keep them somewhat static, but most servers will use random integer NodeIds that might be completely different after a restart. A server might also expose different objects depending on currently connected hardware or the availability of other external data sources.

Also if your application needs to work with any server implementing a certain companion specification, there is no guarantee that you’ll always find the companion specification’s namespace on the same index in the namespaces array.

To summarize: embedding hardcoded NodeIds in your client code will make your application less robust, less portable and harder to maintain.

What to do?

OPC UA nodes of all classes have two naming related attributes. While the DisplayName contains a LocalizedText name for display purposes, which usually describes the node’s function in one word or a short CamelCase string, the BrowseName attribute holds a QualifiedName, which consists of a namespace index and a name string. It should be unique on its level in the server’s node hierarchy. Its intended usage is building RelativePaths, which consist of BrowseNames and information about the reference connecting the node to the previous node.

As each model in the server has its own namespace, the BrowseName’s namespace index indicates which model a node is from. If a model defines a new object type, variable type or event type with child nodes, all of them have a BrowseName from its namespace. If a model extends an existing type from a different model by inheritance, only the newly added child nodes will have its namespace index in the BrowseName while the inherited children keep their namespace index.

The RelativePath concept lends itself to programming against type definitions instead of specific nodes identified by their NodeId because there is always an unambiguous path from the type’s top level node to each child node deeper in the hierarchy.

OPC UA: Programming against Type Descriptions 1 basysKom, HMI Dienstleistung, Qt, Cloud, Azure

To put this knowledge into practice, OPC UA provides the TranslateBrowsePathsToNodeIds service, which takes a start NodeId and a RelativePath. The last RelativePathElement’s BrowseName may be left empty, which will return all child nodes matching the given reference information. The NodeId(s) obtained from this service can then be used in any OPC UA service.

Most companion specifications define a point of entry like the Machines folder that references the server specific objects that implement a certain object type from that specification. In order to find interesting objects, the Browse service can be called to return all NodeIds and BrowseNames of Object nodes referenced by the point of entry along with the NodeId of their TypeDefinition node. This information is then used as a starting point for TranslateBrowsePathsToNodeIds calls to find the object’s child nodes according to its type.

The Browse and TranslateBrowsePathsToNodeIds services used in conjunction with the server’s namespace array to resolve the URIs of the involved models to a namespace index are a mighty tool to find a certain node on any server implementing a given specification.

Example with Qt OPC UA

The following example demonstrates how to find some selected Identification properties of all woodworking machines on the public umati sample server at opc.tcp://opcua.umati.app using Qt OPC UA. This server contains simulated instances of machines for most companion specifications where the umati initiative is involved and has proven a handy tool to test OPC UA enabled client applications implementing these specifications.

The server provides two simulated woodworking machines:

  • BasicWoodworking only contains the mandatory child nodes of WwMachineType
  • FullWoodworking contains all child nodes (mandatory and optional)
OPC UA: Programming against Type Descriptions 2 basysKom, HMI Dienstleistung, Qt, Cloud, Azure

Resolving NodeIds on the server requires several steps:

  • Read the namespaces array (this is done automatically by QOpcUaClient on connect)
  • Build the NodeId of the Machines folder from the Machinery namespace URI’s index in the namespaces array and the well known numeric identifier, browse its object children and identify all objects of type WwMachineType
  • Build the BrowsePath elements from the corresponding namespaces URIs’s indices in the namespaces array and invoke the TranslateBrowsePathsToNodeIds service (resolveBrowsePath() of QOpcUaNode)
  • Print the resulting NodeIds, if found (LocationGPS is optional and only present in FullWoodworking)
  • Do something with the found NodeIds (in this example, read the value attribute)
#include <QCoreApplication>

#include <QOpcUaClient>
#include <QOpcUaProvider>

class OpcUaDemo : public QObject
{
    Q_OBJECT

public:
    bool init();

signals:
    void done();

private:
    void handleEndpoints(const QList<QOpcUaEndpointDescription> &endpoints,
                         QOpcUa::UaStatusCode statusCode, const QUrl &requestUrl);
    void handleStateChanged(QOpcUaClient::ClientState state);
    void handleNamespaceArrayUpdated(const QStringList &namespaces);

    void findWoodworkingMachines();
    void findWoodworkingIdentification(const QString &nodeId, const QString &machineName);

    void readAndPrintValueAttribute(const QString &nodeId, const QString &machineName,
                                    const QString &propertyName);

    QScopedPointer<QOpcUaClient> m_client;
    std::vector<std::unique_ptr<QOpcUaNode>> m_nodes;
    int m_pendingNodes = 0;

    const QString DiUri = QStringLiteral("http://opcfoundation.org/UA/DI/");
    const QString MachineryUri = QStringLiteral("http://opcfoundation.org/UA/Machinery/");
    const QString WoodworkingUri = QStringLiteral("http://opcfoundation.org/UA/Woodworking/");
};

bool OpcUaDemo::init()
{
    QOpcUaProvider provider;
    if (provider.availableBackends().empty()) {
        qWarning("No backends available");
        return false;
    }

    m_client.reset(provider.createClient(provider.availableBackends().constFirst()));

    if (!m_client) {
        qWarning("Failed to create client");
        return false;
    }

    QObject::connect(m_client.get(), &QOpcUaClient::endpointsRequestFinished,
                     this, &OpcUaDemo::handleEndpoints);
    QObject::connect(m_client.get(), &QOpcUaClient::stateChanged,
                     this, &OpcUaDemo::handleStateChanged);
    QObject::connect(m_client.get(), &QOpcUaClient::namespaceArrayUpdated,
                     this, &OpcUaDemo::handleNamespaceArrayUpdated);

    return m_client->requestEndpoints(QStringLiteral("opc.tcp://opcua.umati.app"));
}

void OpcUaDemo::handleEndpoints(const QList<QOpcUaEndpointDescription> &endpoints,
                                QOpcUa::UaStatusCode statusCode, const QUrl &requestUrl)
{
    for (const auto &endpoint : endpoints) {
        if (endpoint.securityMode() == QOpcUaEndpointDescription::None)
            return m_client->connectToEndpoint(endpoint);
    }

    qWarning("No suitable endpoint found");
    emit done();
}

void OpcUaDemo::handleStateChanged(QOpcUaClient::ClientState state)
{
    qDebug() << "State changed to" << state;

    if (state == QOpcUaClient::ClientState::Disconnected)
        emit done();
}

void OpcUaDemo::handleNamespaceArrayUpdated(const QStringList &namespaces)
{
    // Make sure all required namespaces are present on the server
    if (!namespaces.contains(DiUri)) {
        qWarning() << "Could not find namespace" << DiUri;
        emit done();
    }

    if (!namespaces.contains(MachineryUri)) {
        qWarning() << "Could not find namespace" << MachineryUri;
        emit done();
    }

    if (!namespaces.contains(WoodworkingUri)) {
        qWarning() << "Could not find namespace" << WoodworkingUri;
        emit done();
    }

    findWoodworkingMachines();
}

void OpcUaDemo::findWoodworkingMachines()
{
    // The well-known NodeId of the Machines folder defined by the Machinery nodeset
    const auto node = m_client->node(QOpcUa::nodeIdFromInteger(m_client->namespaceArray().indexOf(MachineryUri),
                                                               1001));

    if (!node) {
        qWarning() << "Failed to create node for the Machines folder";
        emit done();
        return;
    }

    m_nodes.push_back(std::unique_ptr<QOpcUaNode>(node));

    QObject::connect(node, &QOpcUaNode::browseFinished,
                     this, [this](const QList<QOpcUaReferenceDescription> &children,
                            QOpcUa::UaStatusCode statusCode) {
                         bool match = false;
                         // Well known NodeId of the WwMachineType object type
                         const auto wwMachineTypeId =
                             QOpcUa::nodeIdFromInteger(m_client->namespaceArray().indexOf(WoodworkingUri), 2);
                         for (const auto &child : children) {
                             if (child.typeDefinition() == wwMachineTypeId) {
                                 qDebug() << "Found woodworking machine" << child.browseName().name()
                                          << child.targetNodeId().nodeId();
                                 findWoodworkingIdentification(child.targetNodeId().nodeId(),
                                                               child.browseName().name());
                                 match = true;
                             }
                         }

                         if (!match) {
                             qWarning() << "No Woodworking machine found";
                             emit done();
                             return;
                         }
                     });

    QOpcUaBrowseRequest request;
    request.setReferenceTypeId(QOpcUa::namespace0Id(QOpcUa::NodeIds::Namespace0::Organizes));
    request.setNodeClassMask(QOpcUa::NodeClass::Object);

    if (!node->browse(request)) {
        qWarning() << "Failed to dispatch browse request";
        emit done();
        return;
    }
}

void OpcUaDemo::findWoodworkingIdentification(const QString &nodeId, const QString &machineName)
{
    const auto node = m_client->node(nodeId);

    if (!node) {
        qWarning() << "Failed to create node for" << nodeId;
        emit done();
        return;
    }

    m_nodes.push_back(std::unique_ptr<QOpcUaNode>(node));

    QObject::connect(node, &QOpcUaNode::resolveBrowsePathFinished, this,
                     [this, machineName](const QList<QOpcUaBrowsePathTarget> &targets,
                                         const QList<QOpcUaRelativePathElement> &path,
                                         QOpcUa::UaStatusCode statusCode) {
                         if (targets.empty()) {
                             qWarning() << "Node" << machineName << "=>" << path.back().targetName().name()
                                        << "was not found";
                         } else if (!targets.first().isFullyResolved()) {
                             qWarning() << "Node" << machineName << "=>" << path.back().targetName().name()
                                        << "was not fully resolved";
                             return;
                         } else {
                             qDebug() << "Node" << machineName << "=>" << path.back().targetName().name()
                                      << "has NodeId" << targets.first().targetId().nodeId();
                             readAndPrintValueAttribute(targets.first().targetId().nodeId(),
                                                        machineName, path.back().targetName().name());
                         }

                         if (--m_pendingNodes == 0)
                             emit done();
                     });

    // The BrowseNames of the Identification folder's child nodes to resolve
    const QList<QOpcUaQualifiedName> nodeNames {
        m_client->qualifiedNameFromNamespaceUri(DiUri, QStringLiteral("Manufacturer")),
        m_client->qualifiedNameFromNamespaceUri(DiUri, QStringLiteral("SerialNumber")),
        m_client->qualifiedNameFromNamespaceUri(WoodworkingUri, QStringLiteral("LocationGPS")),
        m_client->qualifiedNameFromNamespaceUri(MachineryUri, QStringLiteral("YearOfConstruction")),
    };

    // The BrowseName of the Identification folder of the machine
    const auto identificationName = m_client->qualifiedNameFromNamespaceUri(DiUri, QStringLiteral("Identification"));
    for (const auto &name : nodeNames) {
        const QList<QOpcUaRelativePathElement> path {
            QOpcUaRelativePathElement(identificationName, QOpcUa::ReferenceTypeId::HasAddIn),
            QOpcUaRelativePathElement(name, QOpcUa::ReferenceTypeId::HasProperty),
        };


        if (!node->resolveBrowsePath(path)) {
            qWarning() << "Failed to dispatch resolve request";
            emit done();
            return;
        }

        ++m_pendingNodes;
    }
}

void OpcUaDemo::readAndPrintValueAttribute(const QString &nodeId, const QString &machineName,
                                           const QString &propertyName)
{
    const auto node = m_client->node(nodeId);

    if (!node) {
        qWarning() << "Failed to create node for" << nodeId;
        emit done();
        return;
    }

    m_nodes.push_back(std::unique_ptr<QOpcUaNode>(node));

    QObject::connect(node, &QOpcUaNode::attributeRead, this,
                     [this, machineName, propertyName, node](const QOpcUa::NodeAttributes &attributes) {
                         if (node->valueAttributeError() != QOpcUa::UaStatusCode::Good)
                             qWarning() << "Failed to read" << machineName << "=>"
                                        << propertyName << "with error" << node->valueAttributeError();
                         else
                             qDebug() << "Value of" << machineName << "=>" << propertyName
                                      << "is" << node->valueAttribute();

                         if (--m_pendingNodes == 0)
                             emit done();
                     });

    if (!node->readValueAttribute()) {
        qWarning() << "Failed to dispatch read request for"
                   << machineName << "=>" << propertyName;
        return;
    }

    ++m_pendingNodes;
}

#include "main.moc"

int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);

    OpcUaDemo demo;
    QObject::connect(&demo, &OpcUaDemo::done, &a, &QCoreApplication::quit);

    return demo.init() ? a.exec() : EXIT_FAILURE;
} 

As expected, the two woodworking machines are found, their requested property NodeIds are printed along with their name and reading the value attributes returns the expected values.

Found woodworking machine "BasicWoodworking" "ns=19;i=66382"
Found woodworking machine "FullWoodworking" "ns=20;i=66382"
Node "BasicWoodworking" => "Manufacturer" has NodeId "ns=19;i=59137"
Node "BasicWoodworking" => "SerialNumber" has NodeId "ns=19;i=59132"
Node "BasicWoodworking" => "LocationGPS" was not found
Node "BasicWoodworking" => "YearOfConstruction" has NodeId "ns=19;i=59135"
Node "FullWoodworking" => "Manufacturer" has NodeId "ns=20;i=59160"
Node "FullWoodworking" => "SerialNumber" has NodeId "ns=20;i=59155"
Node "FullWoodworking" => "LocationGPS" has NodeId "ns=20;i=59167"
Node "FullWoodworking" => "YearOfConstruction" has NodeId "ns=20;i=59158"
Value of "BasicWoodworking" => "Manufacturer" is QVariant(QOpcUaLocalizedText, QOpcUaLocalizedText("", "Michael Weinig AG"))
Value of "BasicWoodworking" => "SerialNumber" is QVariant(QString, "123456")
Value of "BasicWoodworking" => "YearOfConstruction" is QVariant(ushort, 2021)
Value of "FullWoodworking" => "Manufacturer" is QVariant(QOpcUaLocalizedText, QOpcUaLocalizedText("", "Michael Weinig AG"))
Value of "FullWoodworking" => "SerialNumber" is QVariant(QString, "123456")
Value of "FullWoodworking" => "LocationGPS" is QVariant(QString, "49.628661 9.654903")
Value of "FullWoodworking" => "YearOfConstruction" is QVariant(ushort, 2021) 

Generalizing the Approach

This minimal example can be extended to support deeper hierarchies by simply extending the BrowsePath with more elements. Depending on the intended architecture and complexity of the model, it might also be a good idea to build a tree of classes that represent OPC UA objects and resolve only the direct child nodes, which might be in turn represented by another custom object class depending on the child’s type.

For any MandatoryPlaceholder or OptionalPlaceholder child nodes of an object type where no fixed BrowseName is specified, a browse call similar to the one that was used to find the woodworking machines in the Machines folder can be employed using a start node resolved via TranslateBrowsePathsToNodeIds.

Some manufacturers who implement an OPC UA companion specification will create their own subtypes of object types like WwMachineType to add company-specific additional information. In order to deal with such a server in a generic way, it is necessary to recursively browse the ObjectTypes hierarchy first to identify the underlying supported object type from the specification. The client implementation will then just ignore any custom additions and treat the object according to the known type.

The demonstrated approach can be implemented with any decent OPC UA client SDK.

Conclusion

With the help of some namespace URIs and well-known NodeIds defined by companion specifications, the demo code has avoided all use of hardcoded NodeIds not defined by the standard.

The approach works for any official companion specification or custom OPC UA model. If implemented correctly, it will never fail, no matter what OPC UA server is on the other side, provided it conforms to the model.

Picture of Jannis Völker

Jannis Völker

Jannis Völker is a software engineer at basysKom GmbH in Darmstadt. After joining basysKom in 2017, he has been working in connectivity projects for embedded devices, Azure based cloud projects and has made contributions to Qt OPC UA and open62541. He has a background in embedded Linux, Qt and OPC UA and holds a master's degree in computer science from the University of Applied Sciences in Darmstadt.

Leave a Reply

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

More Blogarticles

basysKom Newsletter

We collect only the data you enter in this form (no IP address or information that can be derived from it). The collected data is only used in order to send you our regular newsletters, from which you can unsubscribe at any point using the link at the bottom of each newsletter. We will retain this information until you ask us to delete it permanently. For more information about our privacy policy, read Privacy Policy