/QUaServer

Qt C++ wrapper for open62541 server stack

Primary LanguageC++MIT LicenseMIT

QUaServer

This is a Qt based library that provides a C++ wrapper for the open62541 library, and abstraction for the OPC UA Server API.

By abstraction it is meant that some of flexibility provided by the original open62541 server API is sacrificed for ease of use. If more flexibility is required than what QUaServer provides, it is highly recommended to use the original open62541 instead.

The main goal of this library is to provide an object-oriented API that allows quick prototyping for OPC UA servers without having to spend much time in creating complex address space structures.

QUaServer is still work in progress, test properly and use precaution before using in production. Please report any issues you encounter in this repository providing a minimum working code example that replicates the issue and a thorough description.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

To test a QUaServer based application it is recommended to use the UA Expert OPC UA Client.

Table of Contents

How to include QUaServer in your Qt project.

How to create object and variable instances and set their attributes.

How to create methods on the server than can be remotelly called from any client.

How to create non-hierarchical references between nodes and browse such relations.

How to create custom object and variable types.

How to customize the server's properties.

How to implement access control for the server based on user permissions.

How to enable and configure secure encrypted communication between the server and clients.

How to create and emit custom events.

How to serialize and deserialize the server address space to and from disk.

How to enable and store historical data and historical events.

How to create server alarms and conditions.


Include

This library requires at least Qt 5.9 or higher and C++ 11.

To use QUaServer, first a copy of the open62541 shared library is needed. The open62541 repo is included in this project as a git submodule (./depends/open62541.git). So don't forget to clone this repository recursively, or run git submodule update --init --recursive after cloning this repo.

The open62541 amalgamation on can be created using the following QMake command on the amalgamation project included in this repo:

cd ./src/amalgamation
# Windows
qmake -tp vc amalgamation.pro
msbuild open62541.vcxproj /p:Configuration=Debug
msbuild open62541.vcxproj /p:Configuration=Release
# Linux
qmake amalgamation.pro
make all

The ./depends/open62541.git submodule on this repo (used in the amalgamation project), tracks the latest compatible open62541 version, which might not be the most recent version of their master branch. Compatibility of QUaServer with the latest version of open62541 is not always guaranteed.

After compiling the amalgamation, to include QUaServer in your project, just include ./src/wrapper/quaserver.pri into your Qt project file (*.pro file). For example:

QT += core
QT -= gui

CONFIG += c++11

TARGET = my_project
CONFIG += console
CONFIG -= app_bundle

TEMPLATE = app

INCLUDEPATH += $$PWD/

SOURCES += main.cpp

include($$PWD/../../src/wrapper/quaserver.pri)

Examples

This library comes with examples in the ./examples folder, which are explained in detail throughout this document. To build the examples use the examples.pro included in the root of this repository:

# Windows
qmake -r -tp vc examples.pro
msbuild examples.sln /p:Configuration=Debug
# Linux
qmake -r examples.pro
make all

Basics

To start using QUaServer it is necessary to include the QUaServer header as follows:

#include <QUaServer>

To create a server simply create an QUaServer instance and call the start() method:

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

	// create server
	QUaServer server;
	// start server
	server.start();

	return a.exec(); 
}

Note it is necessary to create a QCoreApplication and execute it, because QUaServer makes use of Qt's event loop.

By default the QUaServer listens on port 4840 which is the IANA assigned port for OPC UA applications. To change the listening port, simply pass call the setPort method before starting the server:

server.setPort(8080);

To start creating OPC Objects and Variables it is necessary to get the Objects Folder of the server and start adding instances to it:

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

	QUaServer server;

	// get objects folder
	QUaFolderObject * objsFolder = server.objectsFolder();

	// add some instances to the objects folder
	QUaBaseDataVariable * varBaseData = objsFolder->addBaseDataVariable("my_variable");
	QUaProperty         * varProp     = objsFolder->addProperty("my_property");
	QUaBaseObject       * objBase     = objsFolder->addBaseObject("my_object");
	QUaFolderObject     * objFolder   = objsFolder->addFolderObject("my_folder");

	// set values to variables
	varBaseData->setValue(1);
	varProp->setValue("hola");

	server.start();

	return a.exec(); 
}

Instances must only be added using the QUaServer API, by using the following methods:

  • addProperty : Adds a QUaProperty instance. Properties are the leaves of the Address Space tree and cannot have other children. They are used to charaterise what its parent represents and their value do not change often. For example, an engineering unit or a brand name.

  • addBaseDataVariable : Adds a QUaBaseDataVariable instance. BaseDataVariables are used to hold data which might change often and can have children (Objects, Properties, other BaseDataVariables). An example is the current value of a temperature sensor.

  • addBaseObject : Adds a QUaBaseObject instance. BaseObjects can have children and are used to organize other Objects, Properties, BaseDataVariables, etc. The purpose of objects is to model a real device. For example a temperature sensor which has engineering unit and brand name as properties and current value as a variable.

  • addFolderObject : Adds a QUaFolderObject instance. FolderObjects derive from BaseObjects and can do the same, but are typically use to organize a collection of objects. The so called Objects Folder is a QUaFolderObject instance that always exists on the server to serve as a container for all the user instances.

Once connected to the server, the address space should look something like this:

The string argument passed to these methods defines both the node's initial DisplayName and BrowseName. The DisplayName is the name that is displayed to the user by client applications, it should be a human-friendly name. The BrowseName is the name that is used programmatically by client applications to find children nodes easily in hierarchical node structures. The BrowseName is inmutable once a node instance has been created, and must be unique with respect to its parent, so one parent node cannot have multiple children with the same BrowseName. The DisplayName has no restrictions, so it can be changed programmatically.

The Value is also set for the variables defined in the example above. The DataType of the Value is inferred automatically by QUaServer, but it can also be set explicitly as it will be shown later.

The DisplayName, BrowseName, Value and DataType are OPC Attributes. Depending on the type of the instance (Properties, BaseDataVariables, etc.) it is possible to set different attributes. All OPC instance types derive from the Node type. Similarly, in QUaServer, all the types derive directly or indirectly from the C++ QUaNode abstract class.

The QUaServer API allows to read and write the instances attributes with the following methods:

For all Types

The QUaNode API provides the following methods to access attributes:

QUaLocalizedText displayName   () const;
void             setDisplayName(const QUaLocalizedText &displayName);

QUaLocalizedText description   () const;
void             setDescription(const QUaLocalizedText &description);

quint32 writeMask     () const;
void    setWriteMask  (const quint32 &writeMask);

QUaNodeId nodeId() const;

QString nodeClass() const;

QUaQualifiedName browseName() const;

The nodeId() method returns an object containing the NodeId, which is a unique identifier of the node. This is the only unique identifier of a node within a server, because neither the BrowseName nor DisplayName attributes are unique.

By default a random NodeId is assigned automatically when creating a node instance. It is possible to define a custom NodeId upon instantiation by passing the string XML notation as the second argument to the respective method. If the NodeId is invalid or already exists, creating the instance will fail returning nullptr. For example:

QUaProperty * varProp = objsFolder->addProperty("my_property", "ns=1;s=my_prop");
if(!varProp)
{
	qDebug() << "Creating instance failed!";
}

To notify changes, the QUaNode API provides the following Qt signals:

void displayNameChanged(const QUaLocalizedText &displayName);
void descriptionChanged(const QUaLocalizedText &description);
void writeMaskChanged  (const quint32 &writeMask);

Furthermore, the API also notifies when a child is added to an QUaBaseObject or QUaBaseDataVariable instance:

void childAdded(QUaNode * childNode);

For Variable Types

Both QUaBaseDataVariable and QUaProperty derive from the abstract C++ class QUaBaseVariable which provides the following methods to access the variable's attributes:

QVariant          value() const;
void              setValue(const QVariant &value);

QDateTime         sourceTimestamp() const;
void              setSourceTimestamp(const QDateTime& sourceTimestamp);

QDateTime         serverTimestamp() const;
void              setServerTimestamp(const QDateTime& serverTimestamp);

QUaStatusCode     statusCode() const;
void              setStatusCode(const QUaStatusCode& statusCode);

QMetaType::Type   dataType() const;
void              setDataType(const QMetaType::Type &dataType);

qint32            valueRank() const;
QVector<quint32>  arrayDimensions() const; 

quint8            accessLevel() const;
void              setAccessLevel(const quint8 &accessLevel);

double            minimumSamplingInterval() const;
void              setMinimumSamplingInterval(const double &minimumSamplingInterval);

bool              historizing() const;

The value, sourceTimestamp, serverTimestamp and statusCode can be set in a single call by using the setValue overload:

void setValue(
	const QVariant &value, 
	const QUaStatusCode   &statusCode,
	const QDateTime       &sourceTimestamp,
	const QDateTime       &serverTimestamp
);

The setDataType() can be used to force a data type on the variable value. The following Qt types are supported, as well as their QList<T> and QVector<T> types:

QMetaType::Bool
QMetaType::Char
QMetaType::SChar
QMetaType::UChar
QMetaType::Short
QMetaType::UShort
QMetaType::Int
QMetaType::UInt
QMetaType::Long
QMetaType::LongLong
QMetaType::ULong
QMetaType::ULongLong
QMetaType::Float
QMetaType::Double
QMetaType::QString
QMetaType::QDateTime
QMetaType::QUuid
QMetaType::QByteArray

The setAccessLevel() method allows to set a bit mask to define the overall variable read and write access. Nevertheless, the QUaBaseVariable API provides a couple of helper methods that allow to define the access more easily without needing to deal with bit masks:

// Default : read access true
bool readAccess() const;
void setReadAccess(const bool &readAccess);
// Default : write access false
bool writeAccess() const;
void setWriteAccess(const bool &writeAccess);

Using such methods we could set a variable to be writable, for example:

QUaBaseDataVariable * varBaseData = objsFolder->addBaseDataVariable();
varBaseData->setWriteAccess(true);

When a variable is written from a client, on the server notifications are provided by the void QUaBaseVariable::valueChanged(const QVariant &value, const bool &networkChange) Qt signal.

QObject::connect(varBaseData, &QUaBaseDataVariable::valueChanged, [](const QVariant &value, const bool &networkChange) {
	qDebug() << "New value :" << value << (networkChange ? "(Network)" : "(Server Logic)");
});

The networkChange argument specifies if the value was changed though the network by an OPC client or if the value change was performed programmatically by internal the server logic.

For Object Types

The API provides the following methods to access attributes:

quint8 eventNotifier() const;
void   setEventNotifier(const quint8 &eventNotifier);

#ifdef UA_ENABLE_SUBSCRIPTIONS_EVENTS
bool subscribeToEvents() const;
void setSubscribeToEvents(const bool& subscribeToEvents);
#endif // UA_ENABLE_SUBSCRIPTIONS_EVENTS

The usage of these methods is described in detail in the Events section.

Basics Example

Build and test the basics example in ./examples/01_basics to learn more.


Methods

In OPC UA, BaseObjects instances can have methods. To support this, the QUaBaseObject API has the addMethod() method which allows to define a name for the method and a callback.

Since the Objects Folder is an instance of QUaBaseObject, it is possible to add methods to it directly, for example:

int addNumbers(int x, int y)
{
	return x + y;
}

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

	QUaServer server;

	QUaFolderObject * objsFolder = server.objectsFolder();

	// add a method using callback function
	objsFolder->addMethod("addNumbers", &addNumbers);

	server.start();

	return a.exec(); 
}

Which can be remotely executed using a client.

Note that the QUaServer library automatically deduces the arguments and return types. But only the types supported by the setDataType() method (see the Basics section) are supported by the addMethod() API.

A more flexible way of adding methods is by using C++ Lambdas:

objsFolder->addMethod("increaseNumber", [](double input) {
	double increment = 0.1;
	return input + increment;
});

Using the Lambda Capture it is possible to change Objects or Variables:

auto varNumber = objsFolder->addBaseDataVariable();
varNumber->setDisplayName("Number");
varNumber->setValue(0.0);
varNumber->setDataType(QMetaType::Double);

objsFolder->addMethod("incrementNumberBy", [&varNumber](double increment) {
	double currentValue = varNumber->value().toDouble();
	double newValue = currentValue + increment;
	varNumber->setValue(newValue);
	return true;
});

Using methods we can even delete Objects or Variables:

objsFolder->addMethod("deleteNumber", [&varNumber]() {
	if (!varNumber)
	{
		return;
	}
	delete varNumber;
	varNumber = nullptr;
});

Methods Example

Build and test the methods example in ./examples/02_methods to learn more.


References

OPC UA supports the concept of References to create relations between Nodes. References are categorised in HierarchicalReferences and NonHierarchicalReferences. The HierarchicalReferences are the ones used by most OPC Clients to display the instances tree in their graphical user interfaces.

When adding an instance using the QUaServer API, the library creates the required HierarchicalReference type necessary to display the new instance in the instances tree (it uses the HasComponent, HasProperty or Organizes reference types accordingly).

The QUaServer API also allows to create custom NonHierarchicalReferences that can be used to create custom relations between instances. For example, having a temperature sensor and then define a supplier for that sensor:

// create sensor
QUaBaseObject * objSensor1 = objsFolder->addBaseObject("TempSensor1");
// create supplier
QUaBaseObject * objSupl1 = objsFolder->addBaseObject("Mouser");
// create reference
server.registerReference({ "Supplies", "IsSuppliedBy" });
objSupl1->addReference({ "Supplies", "IsSuppliedBy" }, objSensor1);

The registerReference() method has to be called in order to register the new reference type as a subtype of the NonHierarchicalReferences. If the reference type is not registered before its first use, it is registered automatically on first use.

The registered reference can be observed when the server is running by browsing to /Root/Types/ReferenceTypes/NonHierarchicalReferences. There should be a new entry corresponding to the custom reference.

The references for the supplier object should list the Supplies reference:

The references for the sensor object should list the IsSuppliedBy reference:

The registerReference() actually receives a QUaReference instance as an argument, which is defined as:

struct QUaReference
{
	QString strForwardName;
	QString strInverseName;
};

Both forward and reverse names of the reference have to be defined in order to create the reference. In the example, Supplies is the forward name, and IsSuppliedBy is the reverse name. When adding a reference, by default, it is added in forward mode. This can be changed by adding a third argument to the addReference() method which is true by default to indicate it is forward, false to indicate it is reverse.

// objSupl1 "Supplies" objSensor1
objSupl1->addReference({ "Supplies", "IsSuppliedBy" }, objSensor1, true);
// objSensor2 "IsSuppliedBy" objSupl1
objSensor2->addReference({ "Supplies", "IsSuppliedBy" }, objSupl1, false);

In the example above, both sensors are supplied by the same supplier:

Programmatically, references can be added, removed and browsed using the following QUaNode API methods:

void addReference(const QUaReference &ref, const QUaNode * nodeTarget, const bool &isForward = true);

void removeReference(const QUaReference &ref, const QUaNode * nodeTarget, const bool &isForward = true);

template<typename T>
QList<T*>       findReferences(const QUaReference &ref, const bool &isForward = true);
// specialization
QList<QUaNode*> findReferences(const QUaReference &ref, const bool &isForward = true);

For example, to list all the sensors that are supplied by the supplier:

qDebug() << "Supplier" << objSupl1->displayName() << "supplies :";
auto listSensors = objSupl1->findReferences<QUaBaseObject>({ "Supplies", "IsSuppliedBy" });
for (int i = 0; i < listSensors.count(); i++)
{
	qDebug() << listSensors.at(i)->displayName();
}

And to list the supplier of a sensor:

qDebug() << objSensor1->displayName() << "supplier is: :";
auto listSuppliers = objSensor1->findReferences<QUaBaseObject>({ "Supplies", "IsSuppliedBy" }, false);
qDebug() << listSuppliers.first()->displayName();

Note that when a QUaNode derived instance is deleted, all its references are removed.

To notify when a reference has been added or removed the QUaNode API has the following Qt signals:

void referenceAdded  (const QUaReference & ref, QUaNode * nodeTarget, const bool &isForward);
void referenceRemoved(const QUaReference & ref, QUaNode * nodeTarget, const bool &isForward);

References Example

Build and test the methods example in ./examples/03_references to learn more.


Types

OPC types can be extended by subtyping BaseObjects or BaseDataVariables (Properties cannot be subtyped). Using the QUaServer library, a new BaseObject subtype can be created by deriving from QUaBaseObject. Similarly, a new BaseDataVariable subtype can be created by deriving from QUaBaseDataVariable.

Subtyping is very useful to reuse code. For example, if multiple temperature sensors are to be exposed through the OPC UA Server, it might be worth creating a type for it. Start by sub-classing QUaBaseObject as follows:

In temperaturesensor.h :

#include <QUaBaseObject>

class TemperatureSensor : public QUaBaseObject
{
	Q_OBJECT

public:
	Q_INVOKABLE explicit TemperatureSensor(QUaServer *server);
	
};

In temperaturesensor.cpp :

#include "temperaturesensor.h"

TemperatureSensor::TemperatureSensor(QUaServer *server)
	: QUaBaseObject(server)
{
	
}

There are 3 important requirements when creating subtypes:

  • Inherit from either QUaBaseObject or QUaBaseDataVariable (which in turn inherit indirectly from QObject). The Q_OBJECT macro must be set.

  • Create a public constructor that receives a QUaServer pointer as an argument. Add the Q_INVOKABLE macro to such constructor.

  • In the constructor implementation call the parent constructor (QUaBaseObject, QUaBaseDataVariable or derived parent constructor accordingly).

Once all this is met, elsewhere in the code it is necessary to register the new type in the server using the registerType<T>() method. If not registered, then when creating an instance of the new type, the type will be registered automatically by the library.

An instance of the new type is created using the addChild<T>() method:

#include "temperaturesensor.h"

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

	QUaServer server;

	QUaFolderObject * objsFolder = server.objectsFolder();

	// register new type
	server.registerType<TemperatureSensor>();

	// create new type instance
	auto sensor1 = objsFolder->addChild<TemperatureSensor>("Sensor1");

	server.start();

	return a.exec(); 
}

If the new type was registered correctly, it can be observed by browsing to /Root/Types/ObjectTypes/BaseObjectType. There should be a new entry corresponding to the custom type.

Note that the new TemperatureSensor type has a TypeDefinitionOf reference to the Sensor1 instance. And the Sensor1 instance has a HasTypeDefinition to the TemperatureSensor type.

Adding child Variables, Properties and potentially other Objects to the TemperatureSensor type is achieved through the Qt Property System.

Use the Q_PROPERTY macro to add pointers to types of desired children and the library will automatically instantiate the children once an specific instance of the TemperatureSensor type is created.

#include <QUaBaseObject>
#include <QUaBaseDataVariable>
#include <QUaProperty>

class TemperatureSensor : public QUaBaseObject
{
	Q_OBJECT
	// properties
	Q_PROPERTY(QUaProperty * model READ model)
	Q_PROPERTY(QUaProperty * brand READ brand)
	Q_PROPERTY(QUaProperty * units READ units)
	// variables
	Q_PROPERTY(QUaBaseDataVariable * status       READ status      )
	Q_PROPERTY(QUaBaseDataVariable * currentValue READ currentValue)
public:
	Q_INVOKABLE explicit TemperatureSensor(QUaServer *server);

	QUaProperty * model();
	QUaProperty * brand();
	QUaProperty * units();

	QUaBaseDataVariable * status      ();
	QUaBaseDataVariable * currentValue();
};

The QUaServer library automatically adds the C++ instances as QObject children of the TemperatureSensor instance and assigns them the Q_PROPERTY name as their QObject name. Therefore it is possible retrieve the C++ children using the findChild method.

TemperatureSensor::TemperatureSensor(QUaServer *server)
	: QUaBaseObject(server)
{
	// set defaults
	model()->setValue("TM35");
	brand()->setValue("Texas Instruments");
	units()->setValue("C");
	status()->setValue("Off");
	currentValue()->setValue(0.0);
	currentValue()->setDataType(QMetaType::Double);
}

QUaProperty * TemperatureSensor::model()
{
	return this->browseChild<QUaProperty>("model");
}

QUaProperty * TemperatureSensor::brand()
{
	return this->browseChild<QUaProperty>("brand");
}

QUaProperty * TemperatureSensor::units()
{
	return this->browseChild<QUaProperty>("units");
}

QUaBaseDataVariable * TemperatureSensor::status()
{
	return this->browseChild<QUaBaseDataVariable>("status");
}

QUaBaseDataVariable * TemperatureSensor::currentValue()
{
	return this->browseChild<QUaBaseDataVariable>("currentValue");
}

Be careful when using the browseChild to provide the correct BrowseName, otherwise a null reference can be returned from any of the getter methods.

Note that in the TemperatureSensor constructor it is possible to already make use of the children instances and define some default values for them.

Now it is possible to create any number of TemperatureSensor instances and their children will be created and attached to them automatically.

auto sensor1 = objsFolder->addChild<TemperatureSensor>("Sensor1");
auto sensor2 = objsFolder->addChild<TemperatureSensor>("Sensor2");
auto sensor3 = objsFolder->addChild<TemperatureSensor>("Sensor3");

Any Q_PROPERTY added to the TemperatureSensor declaration that inherits QUaProperty, QUaBaseDataVariable or QUaBaseObject will be exposed through OPC UA. Else the Q_PROPERTY will be created in the C++ instance but not exposed through OPC UA.

To add methods to a subtype, the Q_INVOKABLE macro can be used. The limitations are than only up to 10 arguments can used and the argument types can only be the same supported by the setDataType() method (see the Basics section).

class TemperatureSensor : public QUaBaseObject
{
	Q_OBJECT
	
	// properties, variables, objects ...

public:
	Q_INVOKABLE explicit TemperatureSensor(QUaServer *server);

	// properties, variables, objects ...

	Q_INVOKABLE void turnOn();
	Q_INVOKABLE void turnOff();
};

The implementation is like a normal C++ class method:

void TemperatureSensor::turnOn()
{
	status()->setValue("On");
}

void TemperatureSensor::turnOff()
{
	status()->setValue("Off");
}

If the Q_INVOKABLE macro is not used, then the method is simply not exposed through OPC UA.

One final perk of creating subtypes is the possiblity of creating custom enumerators which can be used as data types for variables. This is done using the Q_ENUM macro:

class TemperatureSensor : public QUaBaseObject
{
	Q_OBJECT
	
	// properties, variables, objects ...

public:
	Q_INVOKABLE explicit TemperatureSensor(QUaServer *server);

	// properties, variables, objects ...
	// methods ...

	enum Units
	{
		C = 0,
		F = 1
	};
	Q_ENUM(Units)

};

Then using the enumerator to set the value of a Variable:

TemperatureSensor::TemperatureSensor(QUaServer *server)
	: QUaBaseObject(server)
{
	// set defaults ...
	// use enum as type
	units()->setDataTypeEnum(QMetaEnum::fromType<TemperatureSensor::Units>());
	units()->setValue(Units::C);
}

Then any client has knowledge of the enum options.

Types Example

Build and test the methods example in ./examples/04_types to learn more.


Server

The QUaServer class constructor not only allows to set a custom port to run the server (see the Basics section), but also to set an SSL certificate so that clients can validate the server. The QUaServer instance also contains methods that allow to customise the server description published through OPC UA.

See the validation document for more details on how validation works.

Create Certificates

Make sure openssl is installed and follow the next commands to create the certificate (on Windows use MSys2, on Linux just use the command line).

The first step is to create a Certificate Authority (CA). The CA will take the role of a system integrator comissioned with installing OPC Servers in a plant. The CA will have to:

  • Create its own public and private key pair.

  • Create its own self-signed certificate.

  • Create its own Certificate Revocation List (CRL).

Keys can be created and transformed into various formats. Ultimately, most OPC UA applications make use of the DER format.

# Create directory to store CA's files
mkdir ca
# Create CA key
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ca/ca.key
# Create self-signed CA cert
openssl req -new -x509 -days 3600 -key ca/ca.key -subj "/CN=juangburgos CA/O=juangburgos Organization" -out ca/ca.crt
# Convert cert to der format
openssl x509 -in ca/ca.crt -inform pem -out ca/ca.crt.der -outform der
# Create cert revocation list CRL file
# NOTE : might need to create in relative path
#        - File './demoCA/index.txt' (Empty)
#        - File './demoCA/crlnumber' with contents '1000'
openssl ca -crldays 3600 -keyfile ca/ca.key -cert ca/ca.crt -gencrl -out ca/ca.crl
# Convert CRL to der format
openssl crl -in ca/ca.crl -inform pem -out ca/ca.der.crl -outform der

The next steps must be applied for each server the system integrator wants to install.

  • Create its own public and private key pair.

  • Create an exts.txt which contain the certificate extensions required by the OPC UA standard.

  • Create its own unsigned certificate, and with it a certificate sign request.

  • Give the certificate sign request to the CA to sign it.

The exts.txt should be as follows:

[v3_ca]
subjectAltName=DNS:localhost,DNS:ppic09,IP:127.0.0.1,IP:192.168.1.18,URI:urn:unconfigured:application
basicConstraints=CA:TRUE
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid,issuer
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth,clientAuth,codeSigning

The subjectAltName must contains all the URLs that will be used to connect to the server. In the example above, clients might connect to the localhost (127.0.0.1) or through the Windows network, using the Windows PC name (ppic09), or through the local network (192.168.1.18).

# Create directory to store server's files
mkdir server
# Create server key
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out server/server.key
# Convert server key to der format
openssl rsa -in server/server.key -inform pem -out server/server.key.der -outform der
# Create server cert sign request
openssl req -new -sha256 \
-key server/server.key \
-subj "/C=ES/ST=MAD/O=MyServer/CN=localhost" \
-out server/server.csr

The CA must now sign the server's certificate sign request to create the signed certificate, appending also the required certificate extensions (exts.txt).

# Sign cert sign request (NOTE: must provide exts.txt)
openssl x509 -days 3600 -req \
-in server/server.csr \
-extensions v3_ca \
-extfile server/exts.txt \
-CAcreateserial -CA ca/ca.crt -CAkey ca/ca.key \
-out server/server.crt
# Convert cert to der format
openssl x509 -in server/server.crt -inform pem -out server/server.crt.der -outform der

Use Certificates

First the CA's certificate and CRL must be copied to the client's software.

In the case of UA Expert, in the user interface go to Settings -> Manage Certificates.... Then click the Open Certificate Location, which opens the file epxlorer to a location similar to:

$SOME_PATH/unifiedautomation/uaexpert/PKI/trusted/certs

The CA's certificate must be copied to this path:

cp ca/ca.crt.der $SOME_PATH/unifiedautomation/uaexpert/PKI/trusted/certs/ca.crt.der

Going one directory up, then in crl is where the CRL must be copied to:

cp ca/ca.der.crl $SOME_PATH/unifiedautomation/uaexpert/PKI/trusted/crl/ca.der.crl

Now the server certificate must be copied next to the QUaServer application:

cp server/server.crt.der $SERVER_PATH/server.crt.der

And in the C++ code the server's certificate contents need to be passed to the setCertificate method before starting the server:

#include <QCoreApplication>
#include <QDebug>
#include <QFile>

#include <QUaServer>

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

	QUaServer server;

	// Load server certificate
	QFile certServer;
	certServer.setFileName("server.crt.der");
	Q_ASSERT(certServer.exists());
	certServer.open(QIODevice::ReadOnly);
	server.setCertificate(certServer.readAll());
	certServer.close();

	server.start();

	return a.exec(); 
}

Now the client is able to validate the server before connecting to it.

Note that eventhough validation required creating and managing cryptographic keys, the communications are yet not encrypted. The files generated in this section are used in the Encryption section to actually encrypt communications.

Server Description

The QUaServer instance also contains methods to add custom server description:

// Add server description
server.setApplicationName ("my_app");
server.setApplicationUri  ("urn:juangburgos:my_app");
server.setProductName     ("my_product");
server.setProductUri      ("juangburgos.com");
server.setManufacturerName("My Company Inc.");
server.setSoftwareVersion ("6.6.6-master");
server.setBuildNumber     ("gvfsed43fs");

This methods should be called before starting the server, else the changes won't be visible until the server is restarted.

This information is then made available to the clients through the Server Object that can be found by browsing to /Root/Objects/Server/ServerStatus/BuildInfo

Server Example

Build and test the server example in ./examples/05_server to learn more.

Some test certificates are included for convenience in ./examples/05_server/ca_files. Do not use them in production, just for testing purposes.


Users

By default, QUaServer instances allow anonymous login. To disable it use the setAnonymousLoginAllowed() method as follows:

server.setAnonymousLoginAllowed(false);

But now it is necessary to create at least one user account to access the server. This can be done using the addUser() method:

server.addUser("juan", "pass123");
server.addUser("john", "qwerty");

The first argument is the username and the second is the password.

Now when trying to connect to the server application without credentials, an error might appear in the client's log:

Error 'BadIdentityTokenInvalid' was returned during ActivateSession

To connect to the server it is necessary now to provide credentials. For example, with the UA Expert client right click the server and select Properties ...:

Then in Authentication Settings select Username Password and introduce the username, and click OK:

Now when connecting, the password will be requested. It is likely that the client will issue a warning:

The reason is that communications are not yet encrypted, therefore the usermame and password will be sent in plain text. So any application that monitors the network (such as Wireshark) can read such messages and read the login credentials.

For the moment ignore the warning by clicking Ignore to connect to the server. In the Encryption section it is detailed how to encrypt communications such that the login credentials are protected from eavesdropping applications.

Having a list of login credentials does not only limit access to the server, but is possible to limit access to individual resources in a per-user basis using the setUserAccessLevelCallback() available in the QUaNode API:

// Create some varibles
QUaFolderObject * objsFolder = server.objectsFolder();
// NOTE : the variables need to be overall writable
//        user-level access is defined later
auto var1 = objsFolder->addProperty("var1");
var1->setWriteAccess(true);
var1->setValue(123);
auto var2 = objsFolder->addProperty("var2");
var2->setWriteAccess(true);
var2->setValue(1.23);

// Give access control to individual variables
var1->setUserAccessLevelCallback([](const QString &strUserName) {
	QUaAccessLevel access;
	// Read Access to all
	access.bits.bRead = true;
	// Write Access only to juan
	if (strUserName.compare("juan", Qt::CaseSensitive) == 0)
	{
		access.bits.bWrite = true;
	}
	else
	{
		access.bits.bWrite = false;
	}
	return access;
});

var2->setUserAccessLevelCallback([](const QString &strUserName) {
	QUaAccessLevel access;
	// Read Access to all
	access.bits.bRead = true;
	// Write Access only to john
	if (strUserName.compare("john", Qt::CaseSensitive) == 0)
	{
		access.bits.bWrite = true;
	}
	else
	{
		access.bits.bWrite = false;
	}
	return access;
});

Note the example above uses C++ Lambdas, but traditional callbacks can be used.

Now if John tries to write var1, he might get a client log error like:

Write to node 'NS0|Numeric|762789430' failed [ret = BadUserAccessDenied]

The user-level access control is implemeted in a cascading fashion, meaning that if a variable does not have an specific UserAccessLevelCallback defined, then it looks if the parent has one and so on. If no node has a UserAccessLevelCallback defined then all access is granted. For example:

auto * obj = objsFolder->addBaseObject("obj");

auto * subobj = obj->addBaseObject("subobj");

auto subsubvar = subobj->addProperty("subsubvar");
subsubvar->setWriteAccess(true);
subsubvar->setValue("hola");

// Define access on top level object, 
// since no specific access is defined on 'subsubvar',
// it inherits the grandparent's
obj->setUserAccessLevelCallback([](const QString &strUserName){
	QUaAccessLevel access;
	// Read Access to all
	access.bits.bRead = true;
	// Write Access only to juan
	if (strUserName.compare("juan", Qt::CaseSensitive) == 0)
	{
		access.bits.bWrite = true;
	}
	else
	{
		access.bits.bWrite = false;
	}
	return access;
});

When creating custom types it is possible to define a default custom access level by reimplementing the userAccessLevel() virtual method. For example:

In customvar.h:

#include <QUaBaseDataVariable>
#include <QUaProperty>

class CustomVar : public QUaBaseDataVariable
{
	Q_OBJECT

	Q_PROPERTY(QUaProperty         * myProp READ myProp)
	Q_PROPERTY(QUaBaseDataVariable * varFoo READ varFoo)
	Q_PROPERTY(QUaBaseDataVariable * varBar READ varBar)

public:
	Q_INVOKABLE explicit CustomVar(QUaServer *server);

	QUaProperty         * myProp();
	QUaBaseDataVariable * varFoo();
	QUaBaseDataVariable * varBar();

	// Reimplement virtual method to define default user access
	// for all instances of this type
	QUaAccessLevel userAccessLevel(const QString &strUserName) override;
};

In customvar.cpp:

#include "customvar.h"

CustomVar::CustomVar(QUaServer *server)
	: QUaBaseDataVariable(server)
{
	this->myProp()->setValue("xxx");
	this->varFoo()->setValue(true);
	this->varBar()->setValue(69);
	this->myProp()->setWriteAccess(true);
	this->varFoo()->setWriteAccess(true);
	this->varBar()->setWriteAccess(true);
}

QUaProperty * CustomVar::myProp()
{
	return this->findChild<QUaProperty*>("myProp");	
}

QUaBaseDataVariable * CustomVar::varFoo()
{
	return this->findChild<QUaBaseDataVariable*>("varFoo");
}

QUaBaseDataVariable * CustomVar::varBar()
{
	return this->findChild<QUaBaseDataVariable*>("varBar");
}

QUaAccessLevel CustomVar::userAccessLevel(const QString & strUserName)
{
	QUaAccessLevel access;
	// Read Access to all
	access.bits.bRead = true;
	// Write Access only to john
	if (strUserName.compare("john", Qt::CaseSensitive) == 0)
	{
		access.bits.bWrite = true;
	}
	else
	{
		access.bits.bWrite = false;
	}
	return access;
}

So any instance of CustomVar will use the reimplemented method by default, unless there exists a more specific callback:

QUaAccessLevel juanCanWrite(const QString &strUserName) 
{
	QUaAccessLevel access;
	// Read Access to all
	access.bits.bRead = true;
	// Write Access only to juan
	if (strUserName.compare("juan", Qt::CaseSensitive) == 0)
	{
		access.bits.bWrite = true;
	}
	else
	{
		access.bits.bWrite = false;
	}
	return access;
}

// ...

auto custom1 = objsFolder->addChild<CustomVar>("custom1");
auto custom2 = objsFolder->addChild<CustomVar>("custom2");

// Set specific callbacks
custom1->varFoo()->setUserAccessLevelCallback(&juanCanWrite);
custom2->setUserAccessLevelCallback(&juanCanWrite);

In the example above, all children variables (myProp, varFoo and varBar) inherit the reimplemented access level defined in the CustomVar class, which allows only john to write. But, the varFoo child of the custom1 instance has an specific callback that will overrule the parent's permission.

The instance custom2 also inherits by default the reimplemented access level defined in the CustomVar class, but the specific callback overrules the inherited permission.

Users Example

Build and test the server example in ./examples/06_users to learn more.


Encryption

In this section the QUaServer library is configured to encrypt communications. Before continuing, make sure to go through the Server section in detail and generate all the required certificates and keys.

To support encryption, it is necessary to add the mbedtls library to the project's dependencies. A copy of a compatible version of the mbedtls library is included in this repo as a git cubmodule in ./depends/mbedtls.git.

The mbedtls library is built automatically when compiling the amalgamation project, by passing the ua_encryption option as follows:

cd ./src/amalgamation
# Windows
qmake "CONFIG+=ua_encryption" -tp vc amalgamation.pro
msbuild open62541.vcxproj
# Linux
qmake "CONFIG+=ua_encryption" amalgamation.pro
make all

To compile the examples, run qmake again over your project to load the new configuration. For example, to update the examples in this repo run:

# Windows
qmake "CONFIG+=ua_encryption" -r -tp vc examples.pro
msbuild examples.sln
# Linux
qmake "CONFIG+=ua_encryption" -r examples.pro
make all

After running qmake it is often necessary to rebuild the complete project to avoid missing symbols errors.

Now copy the server's certificate and private key to the path where your binary is (server.crt.der and server.key.der created in the Server section).

Finally load the certificate and private key in the C++ code and pass them to the QUaServer constructor:

#include <QCoreApplication>
#include <QDebug>
#include <QFile>

#include <QUaServer>

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

	// Load server certificate
	QFile certServer;
	certServer.setFileName("server.crt.der");
	Q_ASSERT(certServer.exists());
	certServer.open(QIODevice::ReadOnly);

	// Load server private key
	QFile privServer;
	privServer.setFileName("server.key.der");
	Q_ASSERT(privServer.exists());
	privServer.open(QIODevice::ReadOnly);

	// Instantiate server by passing certificate and key
	QUaServer server;
	server.setCertificate(certServer.readAll());
	server.setPrivateKey (privServer.readAll());

	certServer.close();
	privServer.close();

	server.start();

	return a.exec(); 
}

Now when the client browses for the server, there should be a new option to connect using Sign & Encrypt which encrypts the communications between clients and the server.

Encryption Example

Build and test the encryption example in ./examples/07_encryption to learn more.


Events

To use events, it is necessary to create a new amalgamation from the open62541 source code that supports events. This can be done by building it with the following commands:

cd ./depends/open62541.git
mkdir build; cd build
# Adjust your Cmake generator accordingly
cmake -DUA_ENABLE_AMALGAMATION=ON -DUA_NAMESPACE_ZERO=FULL -DUA_ENABLE_SUBSCRIPTIONS_EVENTS=ON .. -G "Visual Studio 15 2017 Win64"
  • The -DUA_NAMESPACE_ZERO=FULL option is needed because by default open62541 does not include the complete address space of the OPC UA standard in order to reduce binary size. But to support events, it is actually necessary to have the FULL address space available in the server application.

  • The -DUA_ENABLE_SUBSCRIPTIONS_EVENTS=ON is the flag that enables events.

Note that the amalgamation files are now considerably larger because now they contain the full default OPC UA address space.

Now build the library using the Qt project included in this repo:

cd ./src/amalgamation
# Windows
qmake "CONFIG+=ua_events" -tp vc amalgamation.pro
msbuild open62541.vcxproj
# Linux
qmake "CONFIG+=ua_events" amalgamation.pro
make all

To update the examples to support events:

# Windows
qmake "CONFIG+=ua_events" -r -tp vc examples.pro
msbuild examples.sln
# Linux
qmake "CONFIG+=ua_events" -r examples.pro
make all

After running qmake it is often necessary to rebuild the application to avoid missing symbols errors.

The building process above is similar than the one described in the encryption section. To enable both events and encryption the we have to add both options to QMake:

# Windows
qmake "CONFIG+=ua_encryption ua_events" -r -tp vc examples.pro
# Linux
qmake "CONFIG+=ua_encryption ua_events" -r examples.pro

Events now can be used in the C++ code. To create an event, first is necessary to subtype the QUaBaseEvent class, for example:

In myevent.h:

#include <QUaBaseEvent>

class MyEvent : public QUaBaseEvent
{
    Q_OBJECT

public:
	Q_INVOKABLE explicit MyEvent(QUaServer *server);

};

In myevent.cpp:

#include "myevent.h"

MyEvent::MyEvent(QUaServer *server)
	: QUaBaseEvent(server)
{

}

The same rules apply as when subtyping Objects or Variables (see the Types section).

Events must have an originator node, which can be any object in the address space that allows to subscribe to events. This is defined in the EventNotifier attribute which can be accesed through the QUaBaseObject API:

quint8 eventNotifier() const;
void setEventNotifier(const quint8 &eventNotifier);

The value should be an enumeration, but to simplify the usage, there are a couple of helper methods:

bool subscribeToEvents() const;
void setSubscribeToEvents(const bool& subscribeToEvents);

The setSubscribeToEvents(true) enables events for the object while setSubscribeToEvents(false) disables them. By default events are disabled for all objects. Except for the Server Object.

If there is an event which does not originate from any object, then is necessary to use the Server Object to create and trigger the event. An event is instantiated using the createEvent<T>() method:

auto event = server.createEvent<MyEvent>();

Note that for events there is no need to define a BrowseName upon instantiation, that is because events are not normally exposed in the address space (with the exception of Alarms and Conditions).

Once an event is created, some event variables can be set to define the event information. This is provided by the inherited QUaBaseEvent API:

QString sourceName() const;
void setSourceName(const QString &strSourceName);

QDateTime time() const;
void setTime(const QDateTime &dateTime);

QString message() const;
void setMessage(const QString &strMessage);

quint16 severity() const;
void setSeverity(const quint16 &intSeverity);
  • SourceName : Description of the source of the Event.

  • Time : Time (in UTC) the Event occurred. It comes from the underlying system or device.

  • Message : Human-readable description of the Event.

  • Severity : Urgency of the Event. Value from 1 to 1000, with 1 being the lowest severity and 1000 being the highest.

The variables must be set before triggering the event. Then, the event can be triggered with the trigger() method.

In order to be able to test the events though, it is necessary to have a mechanism to trigger events on demand. One option is to create a method to trigger the event:

auto event = server.createEvent<MyEvent>();

objsFolder->addMethod("triggerServerEvent", [&event]() {
	// set event information
	event->setSourceName("Server");
	event->setMessage("An event occured in the server");
	event->setTime(QDateTime::currentDateTimeUtc());
	event->setSeverity(100);
	// trigger event
	event->trigger();	
});

In order to visualize events, some clients require a special events window. For example in UA Expert, click the Add Document button, then select Event View and click Add. Then drag and drop the Server Object (/Root/Objects/Server) to the Configuration window. Now is possible to see events.

The event can be triggered any number of times, and its variables can be updated to new values at any point. Once is not needed anymore, the event can be deleted:

delete event;

If it is desired to trigger events with an specific object as originator, simply create the event using that object's createEvent<T>() method:

QUaFolderObject * objsFolder = server.objectsFolder();
auto obj = objsFolder->addBaseObject("obj");

// Enable object for events
obj->setSubscribeToEvents(true);
// Create event with object as originator
auto obj_event = obj->createEvent<MyEvent>();

But now on the client it is necessary to drag and drop the originator object to the Configuration window.

Events Example

Build and test the events example in ./examples/08_events to learn more.


Serialization

The QUaServer supports creating and destroying nodes at runtime. Therefore it is possible for a client to modify the server's Address Space remotely. This can be achieved, for example, through methods:

QUaFolderObject * objsFolder = server.objectsFolder();

objsFolder->addMethod("CreateVariable", [objsFolder](QString strVariableName) {
	if (objsFolder->browseChild(strVariableName))
	{
		return QString("Error : Variable %1 already exists.").arg(strVariableName);
	}
	auto newVar = objsFolder->addBaseDataVariable(strVariableName);
	return QString("Success : Variable %1 created.").arg(strVariableName);
});

objsFolder->addMethod("DestroyVariable", [objsFolder](QString strVariableName) {
	auto var = objsFolder->browseChild(strVariableName);
	if (!var)
	{
		return QString("Error : Variable %1 does not exists.").arg(strVariableName);
	}
	delete var;
	return QString("Success : Variable %1 destroyed.").arg(strVariableName);
});

Note it is possible to make UaExpert refresh the Address Space automatically, by compiling the snippet above with qmake "CONFIG+=ua_events".

All the nodes created at runtime exist only in memory, so if the server program is restarted, all those nodes will be lost.

To solve this issue, QUaNode provides a serialization API to help saving the current state of the Address Space to disk, and also to be able to restore it from disk.

To save to disk, QUaNode provides the serialize method:

template<typename T>
bool serialize(T& serializer, QQueue<QUaLog> &logOut);

Where T is any C++ type implementing the following interface:

// required API for QUaNode::serialize
bool writeInstance(
	const QUaNodeId& nodeId,
	const QString& typeName,
	const QMap<QString, QVariant>& attrs,
	const QList<QUaForwardReference>& forwardRefs,
	QQueue<QUaLog>& logOut
);

When the serialize method is called over a node instance, it will call the serializer's writeInstance method recursivelly, starting with the node instance that called it and all its descendants. If the writeInstance method returns false, the recursion stops, and the call to serialize also returns false. Any useful log messages should be added to the logOut queue.

It is then responsability of the writeInstance implementation to save to disk the node's information (nodeId, attrs and forwardRefs) in a sensible manner. For example, in the ./examples/09_serialization example, the QUaXmlSerializer class implements serialization to XML in the following format:

<?xml version='1.0' encoding='UTF-8'?>
<nodes>
 <n nodeId="ns=0;i=85" browseName="Objects" description="" eventNotifier="0" displayName="Objects" writeMask="0">
  <r forwardName="Organizes" targetType="QUaBaseObject" targetNodeId="ns=1;s=my_obj" inverseName="OrganizedBy"/>
  <r forwardName="Organizes" targetType="QUaFolderObject" targetNodeId="ns=0;i=1592406929" inverseName="OrganizedBy"/>
  <r forwardName="Organizes" targetType="QUaBaseDataVariable" targetNodeId="ns=0;i=2501818547" inverseName="OrganizedBy"/>
  <r forwardName="Organizes" targetType="QUaProperty" targetNodeId="ns=1;s=my_prop" inverseName="OrganizedBy"/>
 </n>
 <n nodeId="ns=1;s=my_obj" browseName="my_object" description="" eventNotifier="0" displayName="my_object" writeMask="0">
  <r forwardName="FriendOf" targetType="TemperatureSensor" targetNodeId="ns=0;i=2070436686" inverseName="FriendOf"/>
  <r forwardName="HasProperty" targetType="QUaProperty" targetNodeId="ns=0;i=2687773104" inverseName="PropertyOf"/>
  <r forwardName="HasOrderedComponent" targetType="QUaBaseObject" targetNodeId="ns=0;i=4261035154" inverseName="OrderedComponentOf"/>
  <r forwardName="HasOrderedComponent" targetType="QUaFolderObject" targetNodeId="ns=0;i=2452012465" inverseName="OrderedComponentOf"/>
 </n>
 <!-- more nodes ... -->
</nodes>

It simply writes down a list of nodes, each node with the <n> XML tag and all the node's attributes as XML attributes. Each <n> contains a list of <r> XML tags as children listing the forward references for that node. This is all the information required to serialize the state of the Address Space.

Note that the typeName is not serialized as an XML attribute. The typeName is passed to the writeInstance method to allow the user to organize the data by type if desired. In the XML serialization this is not necessary, but if serializing to SQL, knowing the typeName might be useful to store all instance of a type in their own table.

The type T can optionally implement the following interface:

// optional API for QUaNode::serialize
bool serializeStart(QQueue<QUaLog>& logOut);

// optional API for QUaNode::serialize
bool serializeEnd(QQueue<QUaLog>& logOut);

Implementing such methods can be useful to perform intialization tasks such as opening a file for writing, and to perform clean up tasks such as closing the file or release any other resources.

To serialize all node instances in the Address Space, serialize should be called over the Objects Folder of the server, for example:

objsFolder->addMethod("Serialize", [objsFolder](QString strFileName) {
	QUaXmlSerializer serializer;
	QQueue<QUaLog> logOut;
	if (!serializer.setXmlFileName(strFileName, logOut))
	{
		return QString("Error in file name.");
	}
	if (!objsFolder->serialize(serializer, logOut))
	{
		return QString("Error serializing. Check the logOut.");
	}
	return QString("Success : Serialized to %1 file.").arg(strFileName);
});

To restoring the Address Space from disk, QUaNode provides the deserialize method:

template<typename T>
bool deserialize(T& deserializer, QQueue<QUaLog>& logOut);

Where T is any C++ type implementing the following interface:

// required API for QUaNode::deserialize
bool readInstance(
	const QUaNodeId &nodeId,
	const QString &typeName,
	QMap<QString, QVariant> &attrs,
	QList<QUaForwardReference> &forwardRefs,
	QQueue<QUaLog> &logOut
);

// optional API for QUaNode::deserialize
bool deserializeStart(QQueue<QUaLog>& logOut);

// optional API for QUaNode::deserialize
bool deserializeEnd(QQueue<QUaLog>& logOut);

Which work in a similar fashion as the serializing interface (writeInstance, serializeStart and serializeEnd respectively).

When deserializing, it is responsability of the readInstance implementation to return the node's information (typeName, attrs and forwardRefs) for a given nodeId and typeName.

The deserialize call then takes care of restoring the Address Space instatiating any missing nodes or overwriting the attributes of any existing nodes.

To deserialize all node instances in the Address Space, deserialize should be called over the Objects Folder of the server, for example:

objsFolder->addMethod("Deserialize", [objsFolder](QString strFileName) {
	QUaXmlSerializer serializer;
	QQueue<QUaLog> logOut;
	if (!serializer.setXmlFileName(strFileName, logOut))
	{
		return QString("Error in file name.");
	}
	if (!objsFolder->deserialize(serializer, logOut))
	{
		return QString("Error deserializing. Check the logOut.");
	}
	return QString("Success : Deserialized from %1 file.").arg(strFileName);
});

Note that the readInstance interface requires the underlying data source to be queryable by the nodeId. This is not the case for an XML file, therefore the QUaXmlSerializer example loads all the contents of the XML into a queryable structure in memory. As the number of nodes scale, loading all the contents from disk to memory might not be feasible. Then serializing to a queryable database might be a better alternative. In the ./examples/09_serialization example, the QUaSqliteSerializer class implements serialization to a queryable Sqlite database.

Both QUaXmlSerializer and QUaSqliteSerializer classes provided in the ./examples/09_serialization example are just to demonstrate the use if the serialization API. They are by no means the best or most efficient way to serialize the Address Space, the user should provide their own serializer implementation.

Serialization Example

Build and test the events example in ./examples/09_serialization to learn more.


Historizing

The QUaServer supports storing histrical data and historical events, exposing them through the HistoryRead service.

To enable this functionality, build the library using the Qt project included in this repo using the ua_historizing configuration flag:

cd ./src/amalgamation
# Windows
qmake "CONFIG+=ua_historizing" -tp vc amalgamation.pro
msbuild open62541.vcxproj
# Linux
qmake "CONFIG+=ua_historizing" amalgamation.pro
make all

To update the examples to support historizing:

# Windows
qmake "CONFIG+=ua_historizing" -r -tp vc examples.pro
msbuild examples.sln
# Linux
qmake "CONFIG+=ua_historizing" -r examples.pro
make all

To support historizing, QUaServer provides the setHistorizer method:

template<typename T>
bool setHistorizer(T& historizer);

Historizing Data

To historize data, the historizer T can be any C++ type implementing the following interface:

// required API for QUaServer::setHistorizer
// write data point to backend, return true on success
bool writeHistoryData(
	const QUaNodeId &nodeId,
	const QUaHistoryDataPoint &dataPoint,
	QQueue<QUaLog>  &logOut
);
// required API for QUaServer::setHistorizer
// update an existing node's data point in backend, return true on success
bool updateHistoryData(
	const QUaNodeId &nodeId, 
	const QUaHistoryDataPoint &dataPoint,
	QQueue<QUaLog>  &logOut
);
// required API for QUaServer::setHistorizer
// remove an existing node's data points within a range, return true on success
bool removeHistoryData(
	cconst QUaNodeId &nodeId, 
	const QDateTime  &timeStart,
	const QDateTime  &timeEnd,
	QQueue<QUaLog>   &logOut
); 
// required API for QUaServer::setHistorizer
// return the timestamp of the first sample available for the given node
QDateTime firstTimestamp(
	const QString  &strNodeId,
	QQueue<QUaLog> &logOut
) const;
// required API for QUaServer::setHistorizer
// return the timestamp of the latest sample available for the given node
QDateTime lastTimestamp(
	const QUaNodeId &nodeId, 
	QQueue<QUaLog>  &logOut
) const;
// required API for QUaServer::setHistorizer
// return true if given timestamp is available for the given node
bool hasTimestamp(
	const QString   &strNodeId,
	const QDateTime &timestamp,
	QQueue<QUaLog>  &logOut
) const;
// required API for QUaServer::setHistorizer
// return a timestamp matching the criteria for the given node
QDateTime findTimestamp(
	const QUaNodeId &nodeId, 
	const QDateTime &timestamp,
	const QUaHistoryBackend::TimeMatch& match,
	QQueue<QUaLog>  &logOut
) const;
// required API for QUaServer::setHistorizer
// return the number for data points within a time range for the given node
quint64 numDataPointsInRange(
	const QUaNodeId &nodeId, 
	const QDateTime &timeStart,
	const QDateTime &timeEnd,
	QQueue<QUaLog>  &logOut
) const;
// required API for QUaServer::setHistorizer
// return the numPointsToRead data points for the given node
// starting from the numPointsOffset offset after given start time (pagination)
QVector<QUaHistoryDataPoint> readHistoryData(
	const QUaNodeId &nodeId, 
	const QDateTime &timeStart,
	const quint64   &numPointsOffset,
	const quint64   &numPointsToRead,
	QQueue<QUaLog>  &logOut
) const;

To allow storing data it is only necessary to implement writeHistoryData, while the other methods can return default values. The data will be saved to whatever media it is desired.

Implementing only writeHistoryData, means clients won't be able to access the historcal data remotely yet, for that it is necessary to implement more methods of the API, as explained further below.

To store the data, the API passes the NodeId (const QUaNodeId &nodeId) of the variable to be historized.

The QUaHistoryDataPoint structure (const QUaHistoryDataPoint &dataPoint) provides the information that needs to be stored:

struct QUaHistoryDataPoint
{
	QDateTime timestamp;
	QVariant  value;
	quint32   status;
};

Whatever storage media is chosen, it must be queriable first, by NodeId and second, by Timestamp.

For example, if a SQL database is chosen for storage, one approach is to create one table for each NodeId. Each table having three columns for time, value and status respectively. To speed up queries, it is recommended to create indexes over the time column.

Some pseudo-SQL code is used in this documentation to illustrate possible implementation of each API method. For example, to create each NodeId table:

CREATE TABLE ":NodeId" (
	[:NodeId] INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
	[Time] INTEGER NOT NULL,
	[Value] :DataType NOT NULL,
	[Status] INTEGER NOT NULL
);
-- create index to optimize queries by time
CREATE UNIQUE INDEX ":NodeId_Time" ON ":NodeId"(Time);

For writeHistoryData:

INSERT INTO ":NodeId" (Time, Value, Status) VALUES (:Time, :Value, :Status);

All the methods of the API should populate the QQueue<QUaLog> &logOut parameter with log entries describing any error occurred during the storing or querying process.

To allow an OPC UA Client to access the historcal data remotely, it is necessary to further implement the firstTimestamp, lastTimestamp, hasTimestamp, findTimestamp, numDataPointsInRange and readHistoryData. The implementation of this methods is self-describing by their names. Below some pseudo-SQL to illustrate possible implementation:

For firstTimestamp:

SELECT p.Time FROM ":NodeId" p ORDER BY p.Time ASC LIMIT 1;

For lastTimestamp:

SELECT p.Time FROM ":NodeId" p ORDER BY p.Time DESC LIMIT 1;

For hasTimestamp:

SELECT COUNT(*) FROM ":NodeId" p WHERE p.Time = :Time;

For findTimestamp:

-- from above
SELECT p.Time FROM ":NodeId" p WHERE p.Time > :Time ORDER BY p.Time ASC LIMIT 1;
-- from below
SELECT p.Time FROM ":NodeId" p WHERE p.Time < :Time ORDER BY p.Time DESC LIMIT 1;

For numDataPointsInRange:

SELECT COUNT(*) FROM ":NodeId" p WHERE p.Time >= :TimeStart AND p.Time <= :TimeEnd ORDER BY p.Time ASC;

For readHistoryData:

SELECT p.Time, p.Value, p.Status FROM":NodeId" p WHERE p.Time >= :Time ORDER BY p.Time ASC LIMIT :Limit OFFSET :Offset;

To allow modifying historical data, the updateHistoryData and removeHistoryData should be implemented accordingly.

Finally, to historize a variable, the QUaBaseVariable::setHistorizing(const bool& historizing) method should be called. And to allow clients to access its historical data remotelly, the QUaBaseVariable::setReadHistoryAccess(const bool& readHistoryAccess) method should be called. For example:

// create int variable
auto varInt = objsFolder->addBaseDataVariable("MyInt", "ns=0;s=MyInt");
varInt->setValue(0);
// NOTE : must enable historizing for each variable
varInt->setHistorizing(true);
varInt->setReadHistoryAccess(true);

Similarly, to allow clients to modify the historical data, the QUaBaseVariable::setWriteHistoryAccess(const bool& bHistoryWrite) method should be called.

Historizing Events

Historizing events is only possible if the QUaServer project is compiled using the CONFIG+=ua_events flag. See the Events section of this document for more information.

To historize events, the historizer T can be any C++ type implementing the following interface:

// write a event's data to backend
bool writeHistoryEventsOfType(
	const QUaNodeId            &eventTypeNodeId,
	const QList<QUaNodeId>     &emittersNodeIds,
	const QUaHistoryEventPoint &eventPoint,
	QQueue<QUaLog>             &logOut
);
// get event types (node ids) for which there are events stored for the given emitter
QVector<QUaNodeId> eventTypesOfEmitter(
	const QUaNodeId &emitterNodeId,
	QQueue<QUaLog>  &logOut
);
// find a timestamp matching the criteria for the emitter and event type
QDateTime findTimestampEventOfType(
	const QUaNodeId                    &emitterNodeId,
	const QUaNodeId                    &eventTypeNodeId,
	const QDateTime                    &timestamp,
	const QUaHistoryBackend::TimeMatch &match,
	QQueue<QUaLog>                     &logOut
);
// get the number for events within a time range for the given emitter and event type
quint64 numEventsOfTypeInRange(
	const QUaNodeId &emitterNodeId,
	const QUaNodeId &eventTypeNodeId,
	const QDateTime &timeStart,
	const QDateTime &timeEnd,
	QQueue<QUaLog>  &logOut
);
// return the numPointsToRead events for the given emitter and event type,
// starting from the numPointsOffset offset after given start time (pagination)
QVector<QUaHistoryEventPoint> readHistoryEventsOfType(
	const QUaNodeId &emitterNodeId,
	const QUaNodeId &eventTypeNodeId,
	const QDateTime &timeStart,
	const quint64   &numPointsOffset,
	const quint64   &numPointsToRead,
	const QList<QUaBrowsePath> &columnsToRead,
	QQueue<QUaLog>  &logOut
);

To allow storing events it is only necessary to implement writeHistoryEventsOfType, while the other methods can return default values. The events will be saved to whatever media it is desired.

Implementing only writeHistoryEventsOfType, means clients won't be able to access the historcal events remotely yet, for that it is necessary to implement more methods of the API, as explained further below.

To store the events, the API passes the NodeId (const QUaNodeId &eventTypeNodeId) of the event type to be historized, a list of emitter nodeIds and the event data.

The QUaHistoryEventPoint structure provides the information that needs to be stored:

struct QUaHistoryEventPoint
{
	QDateTime timestamp;
	QHash<QUaBrowsePath, QVariant> fields;
};

Historizing events is slightly more complicated than historizing data. Mainly because historical data has always the same struture ({timestamp, value, status}), while event data changes depending on the event type, and there can be any number of event types, including the custom ones.

Another complication is that in OPC UA, the same event can be emitted or notified by different objects (objects that are not necessarily be the event's SourceNode) according to a Event Refereneces organization defined by the OPC specification. So when the event history is queried for a notifier node, all the events that were emitted by this node must be retrieved. This relation is specified by the const QList<QUaNodeId> &emittersNodeIds argument in the writeHistoryEventsOfType historic API method.

Whatever storage media is chosen, events must be queriable first by EventType, then by Emitter, and finally by Timestamp.

For example, if a SQL database is chosen for storage, one approach is to create one table for each EventType. Each table having a fixed number of columns according to the fixed amout of event fields an EventType has. The const QUaHistoryEventPoint &eventPoint argument if the writeHistoryEventsOfType API method is always guaranteed to contain the same event fields for a given type. So the QUaHistoryEventPoint::fields information can be used to create the EventType tables.

CREATE TABLE ":EventTypeNodeId" ( :EventFieldNames :EventFieldTypes );

Then create one able to store the EventType table names, to be able to relate them to a unique index.

CREATE TABLE "EventTypeTableNames"
(
	[EventTypeTableNames] INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
	[TableName] TEXT NOT NULL
);

To speed up queries, it is recommended to create an index over the TableName column.

Then a table per Emitter can be created with fixed a number of columns that help query and relate to the events stored in the EventType tables.

CREATE TABLE ":EmitterNodeId"
(
	[:EmitterNodeId] INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
	[Time] INTEGER NOT NULL,      -- to be able to query emitter's events by time range
	[EventType] INTEGER NOT NULL, -- index of EventTypeTableNames table
	[EventId] INTEGER NOT NULL    -- index of event in its EventType table
);

To speed up queries, it is recommended to create an index over the Time and EventType columns.

CREATE INDEX ":EmitterNodeId_Time_EventType" ON ":EmitterNodeId" (Time, EventType);

Then the procedure to store an event when the writeHistoryEventsOfType API method is called would be:

  • Insert new event in its EventType table, return new event's EventType key.

  • Check if the (EventType) TableName already in the EventTypeTableNames table else insert it, fetch the EventTypeTableNames key.

  • Insert the key of the new event and event type in each emitter table.

Then the rest of the API to query the event history could be imlpemented as follows:

For eventTypesOfEmitter:

SELECT n.TableName FROM EventTypeTableNames n 
INNER JOIN 
(
	SELECT DISTINCT EventType FROM ":EmitterNodeId"
) e
ON n.EventTypeTableNames = e.EventType

For findTimestampEventOfType:

-- from above
SELECT e.Time FROM ":EmitterNodeId" e WHERE e.Time >= :Time AND e.EventType = :EventTypeKey ORDER BY e.Time ASC LIMIT 1;
-- from below
SELECT e.Time FROM ":EmitterNodeId" e WHERE e.Time < :Time AND e.EventType = :EventTypeKey ORDER BY e.Time DESC LIMIT 1;

For numEventsOfTypeInRange:

SELECT COUNT(*) FROM ":EmitterNodeId" e WHERE e.Time >= :TimeStart AND e.Time <= :TimeEnd AND e.EventType = :EventTypeKey ORDER BY e.Time ASC;

For readHistoryEventsOfType :

SELECT * FROM ":EventTypeNodeId" t 
INNER JOIN 
(
	SELECT EventId FROM ":EmitterNodeId" e WHERE e.Time >= :TimeStart AND e.EventType = :EventTypeKey ORDER BY e.Time ASC LIMIT :Limit OFFSET :Offset
) e
ON t.EventTypeNodeId = e.EventId;

Historizing Example

The quainmemoryhistorizer.cpp file shows an example of historical data and event storage in memory, while the quasqlitehistorizer.cpp file shows an example of historical storage using Sqlite.

Note that these examples are provided for illustration purposes only and not for production. The user is encouraged to implement (and if possible, share) their own historizer implementations.

Build and test the historizing example in ./examples/10_historizing to learn more.


Alarms

At the time of writing, alarms and conditions are considered an EXPERIMENTAL feature in the open62541 library, therefore the same applies for QUaServer. Please use with caution.

To use alarms and conditions, it is necessary to create a new amalgamation from the open62541 source code that supports alarms. This can be done by building it with the following commands:

cd ./depends/open62541.git
mkdir build; cd build
# Adjust your Cmake generator accordingly
cmake -DUA_ENABLE_AMALGAMATION=ON -DUA_NAMESPACE_ZERO=FULL -DUA_ENABLE_SUBSCRIPTIONS_EVENTS=ON -DUA_ENABLE_SUBSCRIPTIONS_ALARMS_CONDITIONS=ON .. -G "Visual Studio 15 2017 Win64"
  • The -DUA_NAMESPACE_ZERO=FULL option is needed because by default open62541 does not include the complete address space of the OPC UA standard in order to reduce binary size. But to support events, it is actually necessary to have the FULL address space available in the server application.

  • The -DUA_ENABLE_SUBSCRIPTIONS_EVENTS=ON is the flag that enables events.

  • The -DUA_ENABLE_SUBSCRIPTIONS_ALARMS_CONDITIONS=ON is the flag that enables alarms and conditions.

Note that the amalgamation files are now considerably larger because now they contain the full default OPC UA address space.

Now build the library using the Qt project included in this repo:

cd ./src/amalgamation
# Windows
qmake "CONFIG+=ua_alarms_conditions" -tp vc amalgamation.pro
msbuild open62541.vcxproj
# Linux
qmake "CONFIG+=ua_alarms_conditions" amalgamation.pro
make all

To update the examples to support events:

# Windows
qmake "CONFIG+=ua_alarms_conditions" -r -tp vc examples.pro
msbuild examples.sln
# Linux
qmake "CONFIG+=ua_alarms_conditions" -r examples.pro
make all

After running qmake it is often necessary to rebuild the application to avoid missing symbols errors.

Two types of alarms are available out of the box by the QUaServer API:

  • QUaOffNormalAlarm : Used for alarms based on discrete values. Useful not only for alarms based on boolean values, but also any other discrete values such as integers.

  • QUaExclusiveLevelAlarm : Used for alarms based in continuous numeric values. It provides automatic level checking.

QUaOffNormalAlarm

To create a QUaOffNormalAlarm, the first step is to create an object that will be the SourceNode of the events triggered by the alarm. Clients will be then able to subscribe to events emitted by this object in order to track the alarm state.

auto motionSensor = objsFolder->addChild<QUaBaseObject>("motionSensor");

Then a variable is needed that will provide the discrete value that the alarm will monitor. Any variable that contains a discrete value can be used.

auto moving = motionSensor->addBaseDataVariable("moving");
moving->setWriteAccess(true);
moving->setDataType(QMetaType::Bool);
moving->setValue(false);

Finally the QUaOffNormalAlarm can be created based on its SourceNode, setting the variable with the discrete value as an InputNode and defining what the Normal Value of the InputNode should be.

auto motionAlarm = motionSensor->addChild<QUaOffNormalAlarm>("alarm");
motionAlarm->setConditionName("Motion Sensor Alarm");
motionAlarm->setInputNode(moving);
motionAlarm->setNormalValue(false);
motionAlarm->setConfirmRequired(true);

For the alarm to start generating events, first it has to be enabled. This can be done by calling the Enable method of the alarm object through the network using an OPC client or programmatically using the C++ Enable() method.

QUaExclusiveLevelAlarm

To create a QUaExclusiveLevelAlarm, the first step is to create an object that will be the SourceNode of the events triggered by the alarm. Clients will be then able to subscribe to events emitted by this object in order to track the alarm state.

auto levelSensor = objsFolder->addChild<QUaBaseObject>("levelSensor");

Then a variable is needed that will provide the continuous value that the alarm will monitor. Any variable that contains a continuous value can be used.

auto level = levelSensor->addBaseDataVariable("level");
level->setWriteAccess(true);
level->setDataType(QMetaType::Double);
level->setValue(0.0);

Then the QUaExclusiveLevelAlarm can be created based on its SourceNode, setting the variable with the continuous value as an InputNode.

auto levelAlarm = levelSensor->addChild<QUaExclusiveLevelAlarm>("alarm");
levelAlarm->setConditionName("Level Sensor Alarm");
levelAlarm->setInputNode(level);

levelAlarm->setHighLimitRequired(true);
levelAlarm->setLowLimitRequired(true);
levelAlarm->setHighLimit(10.0);
levelAlarm->setLowLimit(-10.0);

By default the QUaExclusiveLevelAlarm does not monitor any limits, so they have to be required explicitly using the QUaExclusiveLevelAlarm API:

// to enabled the monitoring of specific limits
void setHighHighLimitRequired(const bool& highHighLimitRequired);
void setHighLimitRequired    (const bool& highLimitRequired    );
void setLowLimitRequired     (const bool& lowLimitRequired     );
void setLowLowLimitRequired  (const bool& lowLowLimitRequired  );

// to define the limits
double highHighLimit() const;
void setHighHighLimit(const double& highHighLimit);

double highLimit() const;
void setHighLimit(const double& highLimit);

double lowLimit() const;
void setLowLimit(const double& lowLimit);

double lowLowLimit() const;
void setLowLowLimit(const double& lowLowLimit);

For the alarm to start generating events, first it has to be enabled. This can be done by calling the Enable method of the alarm object through the network using an OPC client or programmatically using the C++ Enable() method.

Branches

Support for branches in QUaServer is disabled by default. To enable branches call the setBranchQueueSize method with a value larger than 0. This will create a branch queue in the alarm which will keep the given number of branches in memory. If more branches are created than the size of the queue, the oldest branch will be deleted automatically to avoid memory saturation.

motionAlarm->setBranchQueueSize(10);
levelAlarm->setBranchQueueSize(10);

Historizing of branches is also disabled by default, to enable it, call the setHistorizingBranches method with a true value.

motionAlarm->setHistorizingBranches(true);
levelAlarm->setHistorizingBranches(true);

License

Amalgamation

The amalgamation source code found in ./src/amalgamation is licensed by open62541 under the Mozilla Public License 2.0.

QUaTypesConverter

The source code in the files ./src/wrapper/quatypesconverter.h and quatypesconverter.cpp was copied and adapted from the QtOpcUa repository (files qopen62541valueconverter.h and qopen62541valueconverter.cpp) and is under the LGPL license.

QUaServer

For the rest of the code, the license is MIT.

Copyright (c) 2019 -2020 Juan Gonzalez Burgos